Author SHA1 Message Date
ouyangxq ba6599646f Remove customer truck delivery creation entry 2026-10-20 00:04:41 +08:00
52 changed files with 131 additions and 1066 deletions
+26 -19
View File
@@ -39,25 +39,25 @@
## 模块快速查询 ## 模块快速查询
本表只保留首个入口;完整 API、字段、状态与恢复规则见 [按需业务索引](docs/business-index.md)。从本应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md` 读取命中条款;涉及业务行为变更时必须核对对应细则,纯定位不全文加载。保留所有适用的最近模块指引。 退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”。列表路由 `return-relabel`、独立新建路由 `return-relabel/create` 与详情路由均复用该页面,并分别由 `createPage/detailPage` 明确模式;新建页直接填写海外仓、联系人、客户参考号及 SKU 预报明细,取消或提交后返回列表,不再由 `WarehouseServiceView.vue` 或列表内弹框承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。
表中路径均相对本应用根目录。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。 先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
| 业务 | 首个入口 | | 业务 | 模块入口 | 联查模块或服务端 |
|---|---| |---|---|---|
| 登录、注册与会话 | `src/modules/auth/AGENTS.md`、`src/api/session.ts`、`src/api/http.ts` | | 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常轮换的统一 Access/Refresh Token 会话持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
| 企业资料、成员与角色 | `src/modules/tenant-management/AGENTS.md`、`src/modules/account/AGENTS.md` | | 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 |
| 工作台与系统入口 | `src/modules/dashboard/AGENTS.md`、`src/modules/system/AGENTS.md` | | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/AGENTS.md` | | 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/AGENTS.md` | | 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `src/modules/first-leg/AGENTS.md`、`src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
| 统一订单展示 | `src/modules/order-management/AGENTS.md` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/AGENTS.md` | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
| 客户资金账户、授信与充值申请 | `src/modules/finance/AGENTS.md` | | 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的平台统一静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 海外采购 | `src/modules/cross-border/AGENTS.md` | | 海外采购 | `src/modules/cross-border/routes.ts`、`views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`、`api/procurement.ts` | 独立列表、新建及详情路由;客户范围只取 Token,创建的 `customerName` 只取认证会话企业名称。详情消费独立客户投影,展示履约、已确认箱明细、实际运单和费用结果,不开放内部文件或平台操作。Backend `lastleg-service/procurement/` |
| 美国本土卡与美本土散板 | `src/modules/cross-border/AGENTS.md` | | 美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 两个运输业务共用列表/表单组件,客户范围只取 Token;不消费采购字段或状态。Backend `lastleg-service/` 的 `trucking/`、`looseboard/` 客户接口 |
| 海外仓入库订单 | `src/modules/order-management/AGENTS.md` | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/AGENTS.md` | | 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
## 租户、认证与数据安全 ## 租户、认证与数据安全
@@ -97,7 +97,7 @@ pnpm typecheck
pnpm build pnpm build
``` ```
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。 - 日常变更至少运行 `pnpm typecheck`;交付前运行 `pnpm build`。
- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。 - 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。 - 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。
@@ -110,4 +110,11 @@ pnpm build
## Client 全量验收快速索引 ## Client 全量验收快速索引
全量验收的两类客户可见范围按需查 `docs/business-index.md` 同名节;平台/PDA 操作与内部证据不得因此暴露给客户。普通修改保持最小模块范围,不自动加载验收资料。 当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
| 验收域 | Client 范围与代码入口 | 验收首读 |
|---|---|---|
| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 |
客户侧边界:仍以服务端权限和客户可见字段为准;PDA、平台后台操作和内部证据不因全量验收自动暴露到 Client。财务按 `../../docs/财务管理/` 与 `src/modules/finance/routes.ts` 定位。
-40
View File
@@ -1,40 +0,0 @@
# Client业务详细索引
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
正文代码路径以工作区 `apps/client-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
## 模块快速查询
退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”。列表路由 `return-relabel`、独立新建路由 `return-relabel/create` 与详情路由均复用该页面,并分别由 `createPage/detailPage` 明确模式;新建页直接填写海外仓、联系人、客户参考号及 SKU 预报明细,取消或提交后返回列表,不再由 `WarehouseServiceView.vue` 或列表内弹框承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。
先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常轮换的统一 Access/Refresh Token 会话持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的平台统一静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 海外采购 | `src/modules/cross-border/routes.ts`、`views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`、`api/procurement.ts` | 独立列表、新建及详情路由;客户范围只取 Token,创建的 `customerName` 只取认证会话企业名称。详情消费独立客户投影,展示履约、已确认箱明细、实际运单和费用结果,不开放内部文件或平台操作。Backend `lastleg-service/procurement/` |
| 美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 两个运输业务共用列表/表单组件,客户范围只取 Token;不消费采购字段或状态。Backend `lastleg-service/` 的 `trucking/`、`looseboard/` 客户接口 |
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
## Client 全量验收快速索引
当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
| 验收域 | Client 范围与代码入口 | 验收首读 |
|---|---|---|
| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 |
客户侧边界:仍以服务端权限和客户可见字段为准;PDA、平台后台操作和内部证据不因全量验收自动暴露到 Client。财务按 `../../docs/财务管理/` 与 `src/modules/finance/routes.ts` 定位。
-26
View File
@@ -1,26 +0,0 @@
# Client 个人资料模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/account/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 只有 `views/AccountProfileView.vue`、`utils/accountProfile.ts`;路由 `account/profile`。
- 后端:个人资料归 `iam/`(`PersonalProfileService.java`),登录手机号变更经 `auth/` 同步并撤销会话。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 个人资料查看与修改 | `views/AccountProfileView.vue`;`account/profile` | `../tenant-management/api/merchantIam.ts`;`iam/` |
| 表单校验与展示映射 | `utils/accountProfile.ts` | 服务端字段 |
## 关键边界
- 个人资料只改本人信息;企业资料(含 `contactPhone`)归 `../tenant-management/`,两处不互相回填。
- 登录手机号修改由 IAM/Auth 处理并撤销该账号现有会话,前端不自行清理 Token。
- 敏感字段(证件号、密码)不在页面持久化,也不写入日志或快照。
- 保存需要明确成功/失败反馈,失败保留已填内容。
## 验证
- 定向:`pnpm test src/modules/account/<目标测试>`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-30
View File
@@ -1,30 +0,0 @@
# Client 登录与注册模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 页面:`views/LoginView.vue`、`views/RegisterView.vue`、`views/LegalDocumentView.vue`(`/login`、`/register`、`/legal/*`);会话状态在 `store/auth.ts`,请求在 `api/auth.ts`。
- 后端:登录与 Token 归 `auth/`,注册与资质归 `iam/` + `file/`。
## 页面与入口
| 目标 | 页面/入口 | API 与后端 |
|---|---|---|
| 登录、记住账号 | `views/LoginView.vue` | `api/auth.ts`、`store/auth.ts`;`auth/` |
| 企业注册与资质上传 | `views/RegisterView.vue` | `api/auth.ts`;`iam/`、`file/` |
| 用户协议与隐私政策 | `views/LegalDocumentView.vue`、`legalDocuments.ts` | 静态文案 |
| 认证壳层与布局 | `components/AuthShell.vue` | — |
| 令牌解析与刷新 | `store/auth.ts`、`utils/jwt.ts` | `../../api/http.ts`、`../../api/session.ts` |
## 关键边界
- 页面不拼 `Authorization`、不自行持久化密码;Access/Refresh Token 只在 `store/auth.ts` 与 `src/api/session.ts` 编排。
- 注册时登录手机号同时初始化公司联系电话,注册完成后两者独立维护;企业资料只改 `contactPhone`。
- “记住账号”只保存登录 ID,不改变 Token 的持久化与续期策略。
- 注册必填资质仅营业执照与统一社会信用代码,法人身份证选填;上传成功不等于审核通过。
- 并发或跨标签页 401 共用一次刷新,每个原请求最多重放一次。
## 验证
- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或注册字段变化同时核对 `auth/`、`iam/` 合同。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-30
View File
@@ -1,30 +0,0 @@
# Client 跨境业务模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/cross-border/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,业务细则见 `../../../docs/business-index.md`。
- 三条业务线共用本模块:`procurement`(海外采购)、`domestic-trucking`(本土卡车)、`pallet-shipping`(美本土散板);每条都是独立列表 + 建单 + 详情路由。
- 后端只查 `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/`;纯页面与文案不读后端。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 海外采购列表、建单与详情 | `views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`;`cross-border/procurement`、`.../create`、`.../:orderNo/detail` | `api/procurement.ts`、`procurementSubmission.ts`、`procurementPresentation.ts`;`lastleg-service/procurement/` |
| 本土卡车列表、建单与详情 | `views/DomesticTruckingView.vue`、`components/TransportDemandForm.vue`;`cross-border/domestic-trucking`、`.../create`、`.../:orderNo/detail` | `api/businessOrders.ts`、`transportDemandForm.ts`;`lastleg-service/trucking/` |
| 美本土散板列表、建单与详情 | `views/PalletShippingView.vue`;`cross-border/pallet-shipping`、`.../create`、`.../:orderNo/detail` | `api/businessOrders.ts`;`lastleg-service/looseboard/` |
| 通用业务单展示与车次安排 | `components/BusinessOrdersView.vue`、`fleetArrangement.ts` | `api/businessOrders.ts`;`lastleg-service/fleet/trip/` |
| 柜型与规格换算 | `containerSpecifications.ts` | 本地换算,服务端复核 |
## 关键边界
- 三条线都是客户自助下单,提交后由平台审核;客户侧不提供审核、改价或内部作业动作。
- 客户与仓库范围只取服务端当前租户上下文,不从前端参数推断;跨客户查询一律拒绝。
- 提交需要幂等键、提交中状态与可恢复失败反馈;失败保留已填内容。
- 海外采购的购买、装箱、面单、交寄与国内核费是一条独立链路,改节点前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`(工作区路径)。
- 页面承载方式按已确认原型:列表 + 独立建单/详情路由,不改为弹框或抽屉。
## 验证
- 定向:`pnpm test src/modules/cross-border/<目标测试>`;接口或类型变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -163,10 +163,3 @@ test('replay rejection cannot discard an earlier unknown creation outcome', asyn
assert.deepEqual(calls[1], calls[0]) assert.deepEqual(calls[1], calls[0])
} finally { harness.dispose() } } finally { harness.dispose() }
}) })
test('purchase category is optional while its length remains bounded', () => {
const payload = presentation.buildProcurementCreate({ ...draft(), purchaseCategory: ' ' }, 'Company', 'key')
assert.equal(payload.purchaseCategory, '')
assert.equal(submission.restoreProcurementSubmission(JSON.stringify(payload)).purchaseCategory, '')
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/)
})
@@ -101,12 +101,11 @@ export function draftBudgetTotal(draft: ProcurementDraft) {
} }
export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate { export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate {
for (const [field, label, max] of [ for (const [field, label, max] of [
['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], ['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000],
['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64], ['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64],
] as const) { ] as const) {
if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`) if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`)
} }
if (draft.purchaseCategory.trim().length > 128) throw new Error('采购品类超过长度限制')
if (!customerName.trim()) throw new Error('当前企业信息尚未加载,请刷新登录信息后重试') if (!customerName.trim()) throw new Error('当前企业信息尚未加载,请刷新登录信息后重试')
if (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数') if (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数')
const price = cents(draft.budgetUnitPrice) const price = cents(draft.budgetUnitPrice)
@@ -147,7 +147,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-alert v-if="submitError" class="submit-error" :title="submitError" type="error" :closable="false" /> <el-alert v-if="submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
<el-form :model="form" label-position="top" :disabled="submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent="submit"> <el-form :model="form" label-position="top" :disabled="submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent="submit">
<h3>商品与预算</h3><div class="procurement-form-grid"> <h3>商品与预算</h3><div class="procurement-form-grid">
<el-form-item label="采购品类"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类(选填)" /></el-form-item> <el-form-item label="采购品类" required><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="商品名称" required><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item> <el-form-item label="商品名称" required><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="规格、型号" /></el-form-item> <el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="规格、型号" /></el-form-item>
<el-form-item label="采购数量(件)" required><el-input v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item> <el-form-item label="采购数量(件)" required><el-input v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
@@ -158,11 +158,12 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<h3>最终国内收件资料</h3><div class="procurement-form-grid"> <h3>最终国内收件资料</h3><div class="procurement-form-grid">
<el-form-item label="收件人" required><el-input v-model.trim="form.contactName" maxlength="128" placeholder="实际收件人姓名" /></el-form-item> <el-form-item label="收件人" required><el-input v-model.trim="form.contactName" maxlength="128" placeholder="实际收件人姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item> <el-form-item label="联系电话" required><el-input v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item label="下单企业"><el-input :model-value="companyName" disabled /></el-form-item>
<el-form-item class="full" label="完整国内地址" required><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="省、市、区县、街道及详细门牌" /></el-form-item> <el-form-item class="full" label="完整国内地址" required><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="省、市、区县、街道及详细门牌" /></el-form-item>
<el-form-item class="full" label="采购要求"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="其他采购要求(选填)" /></el-form-item> <el-form-item class="full" label="采购要求"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="其他采购要求(选填)" /></el-form-item>
</div> </div>
</el-form> </el-form>
<footer class="procurement-form-actions"><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer> <footer class="procurement-form-actions"><el-button v-if="canList" :disabled="submitting" @click="returnToList">返回列表</el-button><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer>
</template> </template>
</section> </section>
</section> </section>
-26
View File
@@ -1,26 +0,0 @@
# Client 工作台模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/dashboard/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 只有 `views/DashboardView.vue` 与 `overview.ts`;路由为空路径(登录后默认页)。
- 后端按卡片来源分别查 `order/`、`firstleg/`、`lastleg-service/`;本模块只做聚合展示。
## 页面与入口
| 目标 | 页面/入口 | API 与后端 |
|---|---|---|
| 业务总览卡片与快捷入口 | `views/DashboardView.vue` | `overview.ts`;按卡片对应的服务 |
| 卡片数据映射与跳转 | `overview.ts` | 各业务模块的公开 API |
## 关键边界
- 只展示服务端或既有模块返回的数据,不在首页二次统计或补算。
- 快捷入口跳转带明确业务筛选,不用标题文字反推状态。
- 首页不新增跨客户对比或内部经营指标;客户只能看到本企业数据。
- 卡片加载失败要单卡片降级,不阻塞整页。
## 验证
- 定向:`pnpm test src/modules/dashboard/<目标测试>`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -13,8 +13,8 @@ const primaryActions = computed(() => [
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false }, { title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false }, { title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false }, { title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
{ title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer/create', permission: 'merchant:overseas-warehouse:transfer:create', tone: 'teal', featured: false, actionLabel: '开始办理' }, { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer', permission: 'merchant:overseas-warehouse:transfer:list', tone: 'teal', featured: false, actionLabel: '开始办理' },
{ title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal/create', permission: 'merchant:overseas-warehouse:discard:create', tone: 'brown', featured: false, actionLabel: '开始办理' }, { title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' },
].filter((action) => auth.hasPermission(action.permission))) ].filter((action) => auth.hasPermission(action.permission)))
</script> </script>
-29
View File
@@ -1,29 +0,0 @@
# Client 财务模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/finance/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,账单与资金细则见 `../../../docs/business-index.md`。
- 本模块只做客户可见的账单查询、客户确认与资金账户;费用来源、核销与账期归 `backend finance/`,不在前端重算。
- 后端只查 `finance/`(账单、收款、账户与授信);文件下载归 `file/`。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 客户财务账单列表、筛选与未收余额 | `views/FinanceBillsView.vue`;`finance/bills` | `api/finance.ts`;`finance/` 账单 |
| 账单详情、入仓码联查与客户确认 | `views/FinanceBillDetailView.vue`;`finance/bills/:billNo/detail` | `api/finance.ts`、`adapters.ts`;`finance/` |
| 客户资金账户、授信与充值申请 | `views/FinanceAccountView.vue`;`finance/account` | `api/account.ts`;`finance/` 资金账户 |
| 跨页契约与稳定类型 | `contracts.ts` | 服务端 DTO |
## 关键边界
- 账单行只显示服务端返回的 `inboundCode`;缺失时显示“—”,不用内部 ID 或本地拼接回退。
- 实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,前端不自行汇总或推算。
- 客户确认按服务端允许动作开放,提交金额与状态以服务端返回为准;已确认账单不得在前端改回。
- 充值申请与账户操作携带幂等键与提交中状态,失败保留原输入。
- 费用明细与柜级分摊只展示服务端结果;前端不参与分摊计算,也不把退税额或汇率写死。
## 验证
- 定向:`pnpm test src/modules/finance/<目标测试>`;接口或金额字段变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-35
View File
@@ -1,35 +0,0 @@
# Client 头程模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/first-leg/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,字段与接口细则见 `../../../docs/business-index.md`。
- 模块分层固定:`views/` 页面、`components/` 业务组件、`api/` 请求、`store/` 跨页状态、`types/`、`utils/`、`data/`;跨模块只能从本目录 `public.ts` 导入。
- 后端只查 `order/`(订单读写与批量导入)、`firstleg/`(履约进度、入库费用、报关资料)、`file/`(资质与附件);纯页面与文案不读后端。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 头程订单列表、详情、草稿编辑 | `views/FirstLegOrdersView.vue`;`orders/first-leg`、`orders/first-leg/:orderId/detail`、`orders/first-leg/:draftId/edit` | `api/firstLeg.ts`;`store/firstLegOrders.ts`;`order/` |
| 创建订单入口(选业务类型) | `views/FirstLegOrderCreateHubView.vue`;`orders/first-leg/create` | `components/FirstLegOrderEditorShell.vue` |
| 快速下单(按渠道/目的仓/SKU 生成草稿) | `views/QuickOrderView.vue`;`first-leg/quick-order` | `api/firstLeg.ts`;只生成草稿,不提交、不审核 |
| 中文批量导入与逐行结果 | 订单列表导入入口 | `api/firstLeg.ts`;`order/` 批量导入;入仓码与失败原因逐行展示 |
| 履约进度与入仓码关系 | `components/OrderProgressPanel.vue`、`components/InboundCodeAssociationDialog.vue` | `api/inboundCodeAssociations.ts`、`utils/orderProgress.ts`;`firstleg/` |
| 运输事实与商品图片 | `components/TransportFactsTable.vue`(纯展示)、`utils/transportFacts.ts`、`utils/productImage.ts` | 行数据类型来自 `api/unifiedOrders.ts`;图片读取归 `file/` |
| 入库费用确认与计费重 | `components/InboundBillingPanel.vue` | `api/inboundBilling.ts`;`firstleg/service/MerchantInboundBillingService.java` |
| 装柜后的退税报关资料 | `views/CustomsDocumentsView.vue`、`components/CustomsDocumentsDialog.vue` | `api/customs.ts`、`store/customsPending.ts`、`utils/customsEligibility.ts`;`firstleg/customs/`、`file/` |
| 船期与参考价格 | `views/SailingPricesView.vue`;`first-leg/sailing-prices` | `api/firstLeg.ts`;`firstleg/` 船期价格 |
| 往来单位候选 | `data/firstLegParties.ts`、`api/partners.ts` | `warehouse/` 公共往来单位 |
## 关键边界
- 头程订单只用当前正式读写契约;不得扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时同步删除生产分支、类型、文案、Mock 和测试。
- 快速下单只创建草稿,不提交、不审核;逐行失败必须回填原因与入仓码,不用内部 ID 代替业务编号。
- 入库费用确认携带 `expectedFeeVersion` 与幂等键,金额与冻结状态只认服务端结果;所有订单行都可进入计费重弹框,不按行类型隐藏。
- 报关资料在开始后只读冻结资料,未装柜或无资格时不展示入口;`utils/customsEligibility.ts` 是唯一资格判断入口。
- 模块间协作只能从本模块 `public.ts` 导入,不穿透其他模块的 `views/` 或内部目录。
## 验证
- 定向:`pnpm test src/modules/first-leg/<目标测试>`;接口或类型变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -2,8 +2,6 @@
依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。 依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。
2026-10-20 补充确认:新建、重置头程订单默认选择“卡车自送”;快速下单生成的草稿同步使用该默认值,编辑已有草稿保留原取货方式。
| 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 | | 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 | | 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 |
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
test('计费重确认保留大整数来源和原幂等键,只提交预期重量版本', async () => {
const calls = []
const source = readFileSync(new URL('./chargeableWeights.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const exports = {}
new Function('require', 'exports', code)(() => ({ apiRequest: async request => { calls.push(request); return [] } }), exports)
const id = '9223372036854775806'
await exports.getChargeableWeights(id)
await exports.confirmChargeableWeight({ inboundId: id, fingerprint: 'abc' }, 'original-command')
assert.equal(calls[0].url, `/merchant/v1/first-leg/orders/${id}/chargeable-weights`)
assert.equal(calls[1].url, `/merchant/v1/first-leg/chargeable-weights/${id}/confirm`)
assert.deepEqual(calls[1].headers, { 'Idempotency-Key': 'original-command' })
assert.deepEqual(calls[1].data, { expectedFingerprint: 'abc' })
assert.equal(calls.some(call => call.url.includes('inbound-billing')), false)
})
@@ -1,16 +0,0 @@
import { apiRequest } from '@/api/http'
export interface ChargeableWeight {
inboundId: string
orderId: string
chargeableWeightKg: number
fingerprint: string
confirmed: boolean
confirmedAt: string | null
}
export function getChargeableWeights(orderId: string) {
return apiRequest<ChargeableWeight[]>({ method: 'GET', url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/chargeable-weights` })
}
export function confirmChargeableWeight(weight: ChargeableWeight, key: string) {
return apiRequest<ChargeableWeight>({ method: 'POST', url: `/merchant/v1/first-leg/chargeable-weights/${encodeURIComponent(weight.inboundId)}/confirm`,
headers: { 'Idempotency-Key': key }, data: { expectedFingerprint: weight.fingerprint } })
}
@@ -1,48 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { ref } from 'vue'
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function harness(api, props = { orderId: 'one', canConfirm: true }) {
let changed, disposed
const events = []
const source = readFileSync(new URL('./ChargeableWeightDialog.vue', import.meta.url), 'utf8').split('<script setup lang="ts">')[1].split('</script>')[0]
const code = ts.transpileModule(source + '\nexports.state = { rows, loading, saving, error, confirm, load };', {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const exports = {}
new Function('require', 'exports', 'defineProps', 'defineEmits', code)(name => {
if (name === 'vue') return { ref, watch: (_, callback) => { changed = callback; callback() }, onBeforeUnmount: callback => { disposed = callback } }
if (name === 'element-plus') return { ElMessage: { success() {} } }
return api
}, exports, () => props, () => name => events.push(name))
return { ...exports.state, props, change: () => changed(), dispose: () => disposed(), events }
}
test('确认结果未知时重试原命令,成功后回查并通知工作台刷新', async () => {
const row = { inboundId: '100', fingerprint: 'weight-v1', chargeableWeightKg: 50, confirmed: false }
const commands = []
let fail = true
const h = harness({ getChargeableWeights: async () => [row], confirmChargeableWeight: async (_, key) => {
commands.push(key); if (fail) throw new Error('响应丢失'); row.confirmed = true
} })
await flush(); await h.confirm(row)
assert.equal(h.error.value, '响应丢失'); assert.equal(h.saving.value, '')
fail = false; await h.confirm(row)
assert.equal(commands[0], commands[1]); assert.deepEqual(h.events, ['confirmed'])
assert.equal(h.rows.value[0].confirmed, true); assert.equal(h.saving.value, '')
})
test('切换订单或关闭后丢弃迟到的读取结果,无权限不能确认', async () => {
let resolveOld
const h = harness({ getChargeableWeights: order => order === 'one' ? new Promise(resolve => { resolveOld = resolve }) : Promise.resolve([]),
confirmChargeableWeight: () => { throw new Error('无权限不得写入') } }, { orderId: 'one', canConfirm: false })
h.props.orderId = 'two'; h.change(); await flush()
resolveOld([{ inboundId: 'old' }]); await flush()
assert.deepEqual(h.rows.value, [])
await h.confirm({ inboundId: 'old', fingerprint: 'old' })
assert.deepEqual(h.events, [])
h.dispose()
})
@@ -1,55 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getChargeableWeights, confirmChargeableWeight, type ChargeableWeight } from '../api/chargeableWeights'
const props = defineProps<{ orderId: string; canConfirm: boolean }>()
const emit = defineEmits<{ close: []; confirmed: [] }>()
const rows = ref<ChargeableWeight[]>([])
const loading = ref(false)
const saving = ref('')
const error = ref('')
const commands = new Map<string, string>()
let sequence = 0
let confirmationSequence = 0
async function load() {
const ticket = ++sequence
loading.value = true; error.value = ''; rows.value = []
try { const data = await getChargeableWeights(props.orderId); if (ticket === sequence) rows.value = data }
catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '计费重加载失败' }
finally { if (ticket === sequence) loading.value = false }
}
async function confirm(row: ChargeableWeight) {
if (saving.value || !props.canConfirm) return
const ticket = sequence
const attempt = ++confirmationSequence
const fingerprint = `${row.inboundId}:${row.fingerprint}`
const key = commands.get(fingerprint) ?? `weight-${crypto.randomUUID()}`
commands.set(fingerprint, key); saving.value = row.inboundId; error.value = ''
try {
await confirmChargeableWeight(row, key)
if (ticket !== sequence) return
ElMessage.success('计费重已确认'); emit('confirmed'); await load()
} catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '确认失败,请刷新后重试' }
finally { if (attempt === confirmationSequence) saving.value = '' }
}
watch(() => props.orderId, () => { confirmationSequence++; saving.value = ''; commands.clear(); void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++; confirmationSequence++ })
</script>
<template>
<el-dialog :model-value="true" title="确认计费重" width="560px" :close-on-click-modal="false" @close="emit('close')">
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button v-if="error" :disabled="!!saving" @click="load">重新加载</el-button>
<div v-loading="loading">
<el-empty v-if="!loading && !error && !rows.length" description="暂无已审核的计费重" />
<div v-for="row in rows" :key="row.inboundId" class="weight-row">
<span>来源入仓单:{{ row.inboundId }}<br>计费重:{{ row.chargeableWeightKg }} kg</span>
<el-tag v-if="row.confirmed" type="success">已确认</el-tag>
<el-button v-else-if="canConfirm" type="primary" :disabled="!!saving" :loading="saving === row.inboundId" @click="confirm(row)">确认计费重</el-button>
<span v-else>待确认</span>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.weight-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 0; }
</style>
@@ -2,7 +2,6 @@
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue' import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
import CustomsHelp from './CustomsHelp.vue'
import { import {
MERCHANT_CUSTOMS_STATUS_LABELS, MERCHANT_CUSTOMS_STATUS_LABELS,
deleteMerchantCustomsDocument, deleteMerchantCustomsDocument,
@@ -368,7 +367,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<section class="customs-section"> <section class="customs-section">
<div class="customs-section-title"> <div class="customs-section-title">
<div class="customs-help-heading"><h3>上传资料</h3><CustomsHelp title="报关资料上传说明"><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></CustomsHelp></div> <div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button> <el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
</div> </div>
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码"> <el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
@@ -383,7 +382,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<section class="customs-section clearance-results-section"> <section class="customs-section clearance-results-section">
<div class="customs-section-title"> <div class="customs-section-title">
<h3>海关申报结果</h3> <div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
</div> </div>
<div v-if="clearanceError" class="customs-error" role="alert"> <div v-if="clearanceError" class="customs-error" role="alert">
<strong>海关申报结果加载失败</strong> <strong>海关申报结果加载失败</strong>
@@ -403,7 +402,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
<section class="customs-section"> <section class="customs-section">
<div class="customs-section-title"> <div class="customs-section-title">
<h3>资料版本历史</h3> <div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
</div> </div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" /> <el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list"> <div v-else class="customs-document-list">
@@ -469,7 +468,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; } .clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; } .customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; } .customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-help-heading { display: flex; align-items: center; gap: 4px; } .customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); } .customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; } .customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } .customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
@@ -1,21 +0,0 @@
<script setup lang="ts">
import { Info } from '@lucide/vue'
defineProps<{ title: string }>()
</script>
<template>
<el-tooltip :trigger="['hover', 'focus']" placement="top" effect="light" :show-after="150" :hide-after="100" :enterable="true">
<template #content><div class="customs-help-content"><strong>{{ title }}</strong><slot /></div></template>
<button type="button" class="customs-help" :aria-label="`查看${title}`"><Info :size="14" aria-hidden="true" /></button>
</el-tooltip>
</template>
<style scoped>
.customs-help { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; margin: 0; padding: 0; border: 0; background: transparent; color: var(--muted); cursor: help; vertical-align: middle; }
.customs-help:hover,.customs-help:focus-visible { color: var(--primary); }
.customs-help:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.customs-help-content { max-width: 320px; color: var(--navy); font-size: 12px; font-weight: 400; line-height: 1.6; white-space: normal; }
.customs-help-content > strong { display: block; margin-bottom: 4px; }
.customs-help-content :deep(p) { margin: 0; }
</style>
@@ -20,7 +20,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
assert.match(source, /watch\(\(\) => props\.autoOpen/) assert.match(source, /watch\(\(\) => props\.autoOpen/)
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/) assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /平台推送费用后,可在这里核对并确认/) assert.match(source, /平台推送计费重后,可在这里核对并确认/)
assert.match(source, /@closed="emit\('closed'\)"/) assert.match(source, /@closed="emit\('closed'\)"/)
}) })
@@ -198,19 +198,19 @@ async function confirmFee() {
</section> </section>
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认费用" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')"> <el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认测量与计费数据" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true"> <div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated /> <el-skeleton :rows="4" animated />
<span class="sr-only">正在加载费用</span> <span class="sr-only">正在加载计费重</span>
</div> </div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert"> <div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" /> <TriangleAlert :size="22" />
<div><strong>费用加载失败</strong><span>{{ props.error }}</span></div> <div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button> <el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div> </div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status"> <div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" /> <Clock3 :size="22" />
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送费用后,可在这里核对并确认。</span></div> <div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
</div> </div>
<template v-else> <template v-else>
<dl class="fee-dialog-summary" aria-label="计费汇总"> <dl class="fee-dialog-summary" aria-label="计费汇总">
@@ -7,7 +7,6 @@ import type { TableAction } from '@/components/common/tableActions'
import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '../api/customs' import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '../api/customs'
import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader' import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader'
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue' import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
import CustomsHelp from '../components/CustomsHelp.vue'
import { useCustomsPendingStore } from '../store/customsPending' import { useCustomsPendingStore } from '../store/customsPending'
@@ -99,7 +98,7 @@ onMounted(() => { void load() })
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="报关资料查询条件" :fields="queryFields" @search="search" /> <QueryToolbar v-model="queryModel" layout="grid" ariaLabel="报关资料查询条件" :fields="queryFields" @search="search" />
<section class="customs-card"> <section class="customs-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRecords" :columns="columns" :loading="loading" :total="filteredRecords.length" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="order.id" :empty-text="emptyText"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRecords" :columns="columns" :loading="loading" :total="filteredRecords.length" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="order.id" :empty-text="emptyText">
<template #toolbar><strong class="customs-list-heading">资料列表<CustomsHelp title="资料统计口径"><p>统计为本客户同柜退税货物汇总,资料覆盖按入仓码计。</p></CustomsHelp></strong></template> <template #toolbar><strong>资料列表</strong><span>统计为本客户同柜退税货物汇总,资料覆盖按入仓码计。</span></template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCcw :size="16" />刷新</el-button></template> <template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCcw :size="16" />刷新</el-button></template>
<template v-if="errorMessage" #error><div class="state-error" role="alert"><span>{{ errorMessage }}</span><el-button :loading="loading" @click="load">重新加载</el-button></div></template> <template v-if="errorMessage" #error><div class="state-error" role="alert"><span>{{ errorMessage }}</span><el-button :loading="loading" @click="load">重新加载</el-button></div></template>
<template #orderNo="{ row }">{{ row.order.orderNo || '—' }}</template> <template #orderNo="{ row }">{{ row.order.orderNo || '—' }}</template>
@@ -115,7 +114,6 @@ onMounted(() => { void load() })
<style scoped> <style scoped>
.customs-page { display: grid; gap: var(--client-section-gap); } .customs-page { display: grid; gap: var(--client-section-gap); }
.customs-list-heading { display: flex; align-items: center; gap: 4px; }
.customs-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .customs-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.state-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; } .state-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; }
</style> </style>
@@ -623,7 +623,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/) assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /error\.message \|\| unavailableMessage/) assert.match(source, /error\.message \|\| unavailableMessage/)
assert.match(source, /order\.status === 'DRAFT'/) assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送费用'[\s\S]*: '订单提交后可查看入库结果'/) assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /订单提交后可查看入库结果/) assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/) assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
}) })
@@ -636,10 +636,10 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
test('opens billing-weight confirmation independently from order details', () => { test('opens billing-weight confirmation independently from order details', () => {
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail')) const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
assert.match(handler, /feeConfirmationOpen\.value = true/) assert.match(handler, /feeConfirmationOpen\.value = true/)
assert.match(handler, /loadInboundBilling\(order, '未推送费用'\)/) assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
assert.doesNotMatch(handler, /openDetail\(/) assert.doesNotMatch(handler, /openDetail\(/)
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/) assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
assert.match(source, /unavailable-message="未推送费用"/) assert.match(source, /unavailable-message="未推送计费重"/)
}) })
test('shows billing-weight confirmation only when the backend marks it pending', () => { test('shows billing-weight confirmation only when the backend marks it pending', () => {
@@ -9,7 +9,6 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import type { TableAction } from '@/components/common/tableActions' import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/http' import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue' import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import ChargeableWeightDialog from '@/modules/first-leg/components/ChargeableWeightDialog.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue' import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
@@ -268,7 +267,6 @@ const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('') const inboundBillingError = ref('')
const inboundBillingOrderId = ref('') const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false) const feeConfirmationOpen = ref(false)
const weightConfirmationOrderId = ref('')
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({}) const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>() const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false) const customsDialogOpen = ref(false)
@@ -425,7 +423,7 @@ function emptyDraft(): FirstLegOrderDraft {
destinationCountryCode: 'US', destinationCountryCode: 'US',
destinationWarehouseId: '', destinationWarehouseId: '',
destinationWarehouseName: '', destinationWarehouseName: '',
pickupMethod: 'SELF_TRUCK', pickupMethod: 'SELF_EXPRESS',
trackingNumber: '', trackingNumber: '',
channelCode: '', channelCode: '',
channelName: '', channelName: '',
@@ -1363,11 +1361,8 @@ function getOrderRowActions(row: object): TableAction[] {
if (canCreateOrder.value) { if (canCreateOrder.value) {
actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) }) actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) })
} }
if (canConfirmInboundFee.value && !['DRAFT', 'CANCELLED', 'REJECTED', 'SUBMITTED'].includes(order.status)) {
actions.push({ key: 'weight', label: '确认计费重', run: () => { weightConfirmationOrderId.value = order.id } })
}
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) { if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
actions.push({ key: 'fee', label: '确认费用', run: () => openFeeConfirmation(order) }) actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
} }
if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) { if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) {
actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) }) actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) })
@@ -1476,9 +1471,9 @@ async function loadDetailPage(orderId: string) {
function openFeeConfirmation(order: FirstLegOrder) { function openFeeConfirmation(order: FirstLegOrder) {
resetInboundBillingState() resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送费用' inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送费用') void loadInboundBilling(order, '未推送计费重')
} }
async function retryDetail() { async function retryDetail() {
@@ -1513,7 +1508,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (order.status === 'DRAFT') { if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送费用' ? '未推送计费重'
: '订单提交后可查看入库结果' : '订单提交后可查看入库结果'
return return
} }
@@ -1528,7 +1523,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (error instanceof ApiError && error.code === 'A0404') { if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送费用' ? '未推送计费重'
: error.message || unavailableMessage : error.message || unavailableMessage
} else { } else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败' inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
@@ -1540,7 +1535,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
async function retryInboundBilling() { async function retryInboundBilling() {
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送费用' : '还未测量推送') if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
} }
function handleInboundFeeConfirmed() { function handleInboundFeeConfirmed() {
@@ -2553,14 +2548,13 @@ async function searchOrders(model: QueryModel) {
:data="inboundBilling" :data="inboundBilling"
:loading="inboundBillingLoading" :loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable" :unavailable="inboundBillingUnavailable"
unavailable-message="未推送费用" unavailable-message="未推送计费重"
:error="inboundBillingError" :error="inboundBillingError"
:can-confirm="canConfirmInboundFee" :can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling" @retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed" @confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false" @closed="feeConfirmationOpen = false"
/> />
<ChargeableWeightDialog v-if="weightConfirmationOrderId" :order-id="weightConfirmationOrderId" :can-confirm="canConfirmInboundFee" @close="weightConfirmationOrderId = ''" @confirmed="handleInboundFeeConfirmed" />
<CustomsDocumentsDialog <CustomsDocumentsDialog
v-model="customsDialogOpen" v-model="customsDialogOpen"
:order-id="customsDialogOrderId" :order-id="customsDialogOrderId"
@@ -201,7 +201,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
destinationCountryCode: row.destinationCountryCode, destinationCountryCode: row.destinationCountryCode,
destinationWarehouseId: row.destinationWarehouseId, destinationWarehouseId: row.destinationWarehouseId,
destinationWarehouseName: row.destinationWarehouseName, destinationWarehouseName: row.destinationWarehouseName,
pickupMethod: 'SELF_TRUCK', pickupMethod: 'SELF_EXPRESS',
trackingNumber: '', trackingNumber: '',
channelCode: channel.channelCode, channelCode: channel.channelCode,
channelName: channel.channelName, channelName: channel.channelName,
@@ -76,7 +76,7 @@ watch(originWarehouseId, (value, previous) => { if (!value || previous === '') r
<template> <template>
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form> <div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section> <el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /></el-table></section> <section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
</div> </div>
</template> </template>
-28
View File
@@ -1,28 +0,0 @@
# Client 消息中心模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/notifications/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 页面:`views/NotificationsView.vue`(收件箱)、`views/NotificationDetailView.vue`(详情);状态在 `store/notifications.ts`,请求在 `api/notifications.ts`。
- 后端归 `iam/` 的 `notification/` 子域;契约见 `notification-v1.yaml`。
## 页面与入口
| 目标 | 页面/入口 | API 与后端 |
|---|---|---|
| 消息收件箱、未读与分页 | `views/NotificationsView.vue` | `api/notifications.ts`、`store/notifications.ts`;`iam/notification/` |
| 消息详情与业务跳转 | `views/NotificationDetailView.vue` | `api/notifications.ts` |
| 文案与类型映射 | `presentation.ts`、`contracts.ts` | 服务端模板字段 |
| 对外导出 | `public.ts` | — |
## 关键边界
- 未读计数与已读状态以服务端为准;本地乐观更新后必须回读,不长期保留本地计数。
- 消息跳转只带业务编号(订单号、入仓码、派送号等),不用内部 ID 作参数或标题。
- 只展示当前企业范围内的消息;模板变量由服务端填充,前端不补默认值。
- 消息列表失败要可重试,不清空已有内容。
## 验证
- 定向:`pnpm test src/modules/notifications/<目标测试>`;字段变化同时核对 `notification-v1.yaml`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-30
View File
@@ -1,30 +0,0 @@
# Client 订单管理模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/order-management/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,订单与入仓计划细则见 `../../../docs/business-index.md`。
- 本模块承载客户可见的**统一订单、海外仓入仓计划与派送单**;头程下单与费用确认归 `../first-leg/`。
- 后端只查 `lastleg-service/` 的 `inboundplan/`、`delivery/`;头程订单事实由 `../first-leg/` 与 `order/` 提供。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 我的订单(统一列表) | `views/MyOrdersView.vue`;`orders/my` | 复用 `../first-leg/api/unifiedOrders.ts`;`order/` |
| 海外仓入库订单列表与详情 | `views/InboundOrdersView.vue`;`orders/inbound`、`orders/inbound/:planNo/detail` | `api/inboundPlans.ts`、`inboundPlanDisplay.ts`;`lastleg-service/inboundplan/` |
| 派送单列表 | `views/DeliveryOrdersView.vue`、`views/deliveryOrders.css`;`orders/delivery` | `api/deliveryOrders.ts`;`lastleg-service/delivery/` |
| 派送单详情与签收结果 | `views/DeliveryOrderDetailView.vue`;`orders/delivery/:deliveryNo` | `api/deliveryOrders.ts` |
| 浏览器读屏验收脚本 | `receiptBrowserAcceptance.mjs` | 仅本地验收,不写业务数据 |
## 关键边界
- 入仓计划与派送单对客户只读;任何状态推进由海外仓作业侧完成,客户端不提供完成/发车类动作。
- 派送单按目的 FBA 仓拆分,列表与详情必须显示目的 FBA 仓;不得用单个出库号覆盖整批派送。
- 业务标识用计划号、入仓码、派送号等正式编号;缺失显示“—”,不用内部 ID 回退。
- 客户与仓库范围只取服务端当前租户上下文,不从前端参数推断。
- 分页、筛选与导出只消费服务端结果,不在浏览器重排或本地累计。
## 验证
- 定向:`pnpm test src/modules/order-management/<目标测试>`;接口或类型变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-34
View File
@@ -1,34 +0,0 @@
# Client 海外仓模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/overseas-warehouse/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面与字段细则见 `../../../docs/business-index.md`。
- 五类自助业务共用三个页面文件:`WarehouseServiceView.vue`(贴标、仓储转运、货物废弃)、`ReturnOrdersView.vue`(退货预报与签收)、`ReturnInventoryView.vue`(退货库存与箱号);按路由区分模式。
- 后端只查 `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`、`returnreceipt/`;资源与库位归 `warehouse-overseas-service/`;纯页面与文案不读后端。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 贴标申请列表、新建、详情 | `views/WarehouseServiceView.vue`;`overseas-warehouse/labeling`、`.../create`、`.../:detailNo/detail` | `api/warehouseServices.ts`;`lastleg-service/relabel/` |
| 仓储转运列表、详情与入库测量 | `views/WarehouseServiceView.vue`;`overseas-warehouse/storage-transfer`、`.../:detailNo/detail` | `api/warehouseServices.ts`、`api/transferPickups.ts`;`components/TransferPickupProgressPanel.vue`;`lastleg-service/transfer/` |
| 货物废弃列表、新建与详情 | `views/WarehouseServiceView.vue`;`overseas-warehouse/cargo-disposal`、`.../:detailNo/detail` | `api/warehouseServices.ts`、`utils/discardDocuments.ts`;`lastleg-service/discard/` |
| 退货预报、新建与签收详情 | `views/ReturnOrdersView.vue`;`overseas-warehouse/return-relabel`、`.../create`、`.../:returnNo/detail` | `api/returnOrders.ts`、`utils/returnReceiptDisplay.ts`;`lastleg-service/returnreceipt/` |
| 退货库存查询 | `views/ReturnInventoryView.vue`;`overseas-warehouse/return-inventory` | `api/returnInventory.ts` |
| 退货箱号与箱内明细 | `views/ReturnInventoryView.vue`;`overseas-warehouse/return-cartons`、`.../:cartonMark/detail` | `api/returnInventory.ts`、`utils/returnReceiptDisplay.ts` |
| 换标完工确认与驳回提示 | `components/CompletionConfirmationDialog.vue`、`components/ReviewRejectionNotice.vue`、`components/RelabelDocumentsPanel.vue` | `api/warehouseServices.ts`、`utils/relabelSkuDisplay.ts` |
`views/LabelingView.vue`、`views/ReturnInboundView.vue`、`views/ReturnOutboundView.vue` 当前未在 `routes.ts` 注册;改动前先确认是否已废弃或仅被内嵌,不要据此认定页面开放。
## 关键边界
- 换标申请必须提交正数 CNY `chargeAmount`;金额与资金状态以服务端返回为准,不在客户端模拟冻结成功。
- 退货预报与签收只消费服务端状态与箱号;退货库存按箱号和 SKU 查询,箱内明细以服务端实收为准,不按预报箱数推算。
- 仓储转运的测量与打托由仓库侧完成,客户端只读展示与确认;转运进度以 `TransferPickupProgressPanel.vue` 消费的服务端事实为准。
- 货物废弃在待审锁定与实际确认扣减之间不可回退,前端只按服务端允许动作开放按钮。
- 页面承载方式按已确认原型:贴标、转运、废弃为独立路由页面,退货预报为独立页面,不擅自改为弹框或抽屉。
## 验证
- 定向:`pnpm test src/modules/overseas-warehouse/<目标测试>`;接口或类型变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -1,28 +0,0 @@
import { apiRequest } from '@/api/http'
export interface RelabelQuantity {
sourcePalletCount?: number | string | null
targetPalletCount?: number | string | null
sourceUnitCounts?: Record<string, number | string> | null
labelPieceCount: number | string | null
cartonCount: number | string | null
skuCount: number | string
duplicateCarton: boolean
}
export interface RelabelQuantityTotals {
knownSourcePalletCount?: number | string
unknownSourcePalletOrders?: number | string
knownTargetPalletCount?: number | string
unknownTargetPalletOrders?: number | string
orderCount: number | string
skuCount: number | string
knownLabelPieceCount: number | string
unknownPieceOrders: number | string
knownCartonCount: number | string
unknownCartonOrders: number | string
orders: Record<string, RelabelQuantity>
}
/** 客户范围来自会话,数量汇总覆盖全部筛选结果。 */
export function getRelabelQuantities(params: Record<string, unknown>) {
return apiRequest<RelabelQuantityTotals>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/quantity-summary', params })
}
@@ -2,10 +2,6 @@ import { apiRequest, apiBlobRequest } from '@/api/http'
import type { PageResult } from './warehouseServices' import type { PageResult } from './warehouseServices'
export interface ReturnOrderSummary { export interface ReturnOrderSummary {
remainingQuantity?: number
receivedBoxCount?: number
receivedParcelCount?: number
receivedPalletCount?: number
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }> relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }>
returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string
@@ -34,13 +30,6 @@ export interface ReturnOrderCreate {
export function listReturnOrders(params: Record<string, unknown>) { export function listReturnOrders(params: Record<string, unknown>) {
return apiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params }) return apiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params })
} }
export interface ReturnOrderStatistics {
totals: { orderCount: number; skuTypes: number; expectedQuantity: number; receivedQuantity: number; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }
pageDimensions: Array<{ returnNo: string; remainingQuantity: number; receivedBoxCount: number; receivedParcelCount: number; receivedPalletCount: number }>
}
export function getReturnOrderStatistics(params: Record<string, unknown>) {
return apiRequest<ReturnOrderStatistics>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders/statistics', params })
}
export function getReturnOrder(returnNo: string) { export function getReturnOrder(returnNo: string) {
return apiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` }) return apiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` })
} }
@@ -1,23 +0,0 @@
import { apiRequest } from '@/api/http'
export interface TransferQuantityTotals {
orderCount: number | string
declaredPieceCount: number | string | null
knownDeclaredPieceCount: number | string
unknownDeclaredOrderCount: number | string
receivedPieceCount: number | string | null
knownReceivedPieceCount: number | string
unknownReceivedOrderCount: number | string
declaredCartonCount: number | string
receivedCartonCount: number | string
receivedPalletCount: number | string
availableCartonCount: number | string
shippedCartonCount: number | string
remainingPalletCount: number | string
shippedPalletCount: number | string
}
/** 客户范围由会话决定,汇总使用本次列表完整筛选。 */
export function getTransferQuantities(params: Record<string, unknown>) {
return apiRequest<TransferQuantityTotals>({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers/quantities', params })
}
@@ -25,8 +25,8 @@ export interface RelabelDetail {
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string } completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string }
} }
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string; skuCodes: string[]; skuCount: number; requestedPackages: number; actualPackages: number | null } export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string }
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number; availablePieces?: number | null }> } export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
export interface RelabelInventoryCandidate { export interface RelabelInventoryCandidate {
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { apiRequest } from '@/api/http'
interface Statistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
const props = defineProps<{ query: Record<string, unknown>; revision: number }>()
const statistics = ref<Statistics | null>(null)
const error = ref(false)
let generation = 0
/** 仅消费与列表已提交查询一致的全筛选统计,旧响应不得覆盖新查询。 */
watch(() => [props.query, props.revision], async () => {
const request = ++generation
statistics.value = null; error.value = false
try {
const result = await apiRequest<Statistics>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/statistics', params: props.query })
if (request === generation) statistics.value = result
} catch { if (request === generation) error.value = true }
}, { immediate: true })
/** 缺少商品或托盘事实时保留空值含义。 */
function quantity(value: number | null, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
</script>
<template>
<p aria-live="polite" class="discard-statistics" v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</p>
<p v-else class="discard-statistics">{{ error ? '数量汇总加载失败,请重新查询' : '数量汇总加载中…' }}</p>
</template>
<style scoped>.discard-statistics{margin:10px 0;color:var(--muted);font-size:13px;line-height:1.6}</style>
@@ -1,39 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getRelabelQuantities, type RelabelQuantityTotals } from '../api/relabelQuantities'
const props = defineProps<{ query?: Record<string, unknown>; relabelNo?: string }>()
const data = ref<RelabelQuantityTotals | null>(null)
const error = ref('')
let generation = 0
function dimension(known: number, unknown: number, unit: string) {
if (unknown > 0 && known === 0) return `未提供(${unknown} 票)`
return `已知 ${known} ${unit}${unknown > 0 ? `(${unknown} 票未提供)` : ''}`
}
watch(() => [props.query, props.relabelNo], async () => {
const current = ++generation
data.value = null; error.value = ''
try {
const result = await getRelabelQuantities({ ...props.query, ...(props.relabelNo ? { relabelNo: props.relabelNo } : {}) })
if (current === generation) data.value = result
} catch { if (current === generation) error.value = '数量汇总加载失败,请刷新重试' }
}, { immediate: true, deep: true })
</script>
<template>
<section class="relabel-quantity-panel" aria-label="贴标数量维度">
<p v-if="error" role="alert">{{ error }}</p>
<template v-else-if="data">
<strong>{{ relabelNo ? '本单数量' : '全部筛选结果' }}</strong>
<span>{{ data.orderCount }} 票</span><span>{{ data.skuCount }} 种 SKU</span>
<span>贴标件数:{{ dimension(Number(data.knownLabelPieceCount), Number(data.unknownPieceOrders), '件') }}</span>
<span>箱数:{{ dimension(Number(data.knownCartonCount), Number(data.unknownCartonOrders), '箱') }}</span>
<span>原托盘:{{ dimension(Number(data.knownSourcePalletCount ?? 0), Number(data.unknownSourcePalletOrders ?? data.orderCount), '托') }}</span>
<span>本轮目标托盘:{{ dimension(Number(data.knownTargetPalletCount ?? 0), Number(data.unknownTargetPalletOrders ?? data.orderCount), '托') }}</span>
<p v-if="relabelNo && data.orders[relabelNo]?.duplicateCarton" role="status">存在重复箱唛关联,箱数已去重,贴标件数待核查。</p>
</template>
<span v-else>数量加载中…</span>
</section>
</template>
<style scoped>
.relabel-quantity-panel { display: flex; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, white); margin-bottom: 12px; }
.relabel-quantity-panel p { margin: 0; }
</style>
@@ -1,52 +0,0 @@
<script setup lang="ts">
const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
let drag: { pointerId: number; startX: number; startValue: number; width: number } | null = null
function resize(value: number) {
emit('update:modelValue', Math.min(65, Math.max(35, value)))
}
function startDrag(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
const handle = event.currentTarget as HTMLElement
const width = (handle.parentElement?.getBoundingClientRect().width ?? 0) - 16
if (width <= 0) return
event.preventDefault()
handle.focus()
drag = { pointerId: event.pointerId, startX: event.clientX, startValue: props.modelValue, width }
handle.setPointerCapture(event.pointerId)
}
function moveDrag(event: PointerEvent) {
if (!drag || drag.pointerId !== event.pointerId) return
resize(drag.startValue + (event.clientX - drag.startX) / drag.width * 100)
}
function endDrag(event: PointerEvent) {
if (drag?.pointerId !== event.pointerId) return
drag = null
const handle = event.currentTarget as HTMLElement
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId)
}
function keyResize(event: KeyboardEvent) {
const value = { ArrowLeft: props.modelValue - 2, ArrowRight: props.modelValue + 2, Home: 35, End: 65, Enter: 43 }[event.key]
if (value === undefined) return
event.preventDefault()
resize(value)
}
</script>
<template>
<div class="transfer-pane-divider" role="separator" tabindex="0" aria-label="调整转运信息和换标申请宽度"
aria-orientation="vertical" :aria-valuenow="Math.round(modelValue)" :aria-valuemin="35" :aria-valuemax="65"
aria-controls="transfer-main-pane transfer-relabel-pane" title="左右拖动调整宽度,双击恢复"
@pointerdown="startDrag" @pointermove="moveDrag" @pointerup="endDrag" @pointercancel="endDrag"
@lostpointercapture="drag = null" @keydown="keyResize" @dblclick="resize(43)">
<span aria-hidden="true" />
</div>
</template>
<style scoped>
.transfer-pane-divider { display:flex; width:16px; align-items:center; justify-content:center; align-self:stretch; cursor:col-resize; touch-action:none; user-select:none; outline:none; }
.transfer-pane-divider span { width:4px; height:64px; border-radius:4px; background:#c5d1e1; transition:background .15s; }
.transfer-pane-divider:hover, .transfer-pane-divider:focus-visible { background:#e9f1ff; border-radius:6px; }
.transfer-pane-divider:hover span, .transfer-pane-divider:focus-visible span { background:#3478c8; }
</style>
@@ -1,30 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getTransferQuantities, type TransferQuantityTotals } from '../api/transferQuantities'
const props = defineProps<{ query: Record<string, unknown> }>()
const data = ref<TransferQuantityTotals>()
const error = ref('')
let generation = 0
watch(() => JSON.stringify(props.query), async () => {
const current = ++generation
data.value = undefined; error.value = ''
try { const result = await getTransferQuantities(props.query); if (current === generation) data.value = result }
catch { if (current === generation) error.value = '转运数量汇总加载失败,请重新查询' }
}, { immediate: true })
</script>
<template>
<section class="transfer-quantity-panel" aria-label="仓储转运全筛选汇总">
<p v-if="error" role="alert">{{ error }}</p>
<template v-else-if="data">
<strong>全部筛选结果 · {{ data.orderCount }} 票</strong>
<span>预报:{{ data.declaredPieceCount ?? `未记录(已知 ${data.knownDeclaredPieceCount} 件,${data.unknownDeclaredOrderCount} 票未知)` }}{{ data.declaredPieceCount == null ? '' : ' 件' }} / {{ data.declaredCartonCount }} 箱</span>
<span>历史实收:{{ data.receivedPieceCount ?? `未记录(已知 ${data.knownReceivedPieceCount} 件,${data.unknownReceivedOrderCount} 票未知)` }}{{ data.receivedPieceCount == null ? '' : ' 件' }} / {{ data.receivedCartonCount }} 箱 / {{ data.receivedPalletCount }} 托</span>
<span>当前已清点 {{ data.availableCartonCount }} 箱 · 剩余 {{ data.remainingPalletCount }} 托 · 已出库 {{ data.shippedCartonCount }} 箱 / {{ data.shippedPalletCount }} 托(托盘可重叠)</span>
</template>
<span v-else>转运数量加载中…</span>
</section>
</template>
<style scoped>
.transfer-quantity-panel { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px 16px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, white); }
.transfer-quantity-panel p { margin: 0; }
</style>
-11
View File
@@ -5,17 +5,6 @@ export const overseasWarehouseModule = {
key: 'overseas-warehouse', key: 'overseas-warehouse',
navigationGroup: { title: '海外仓', icon: Warehouse }, navigationGroup: { title: '海外仓', icon: Warehouse },
routes: [ routes: [
{
path: 'overseas-warehouse/storage-transfer/create', name: 'overseas-storage-transfer-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer', createPage: true }, release: 'active',
meta: { title: '新增仓储转运单', workspaceTitle: '仓储转运 · 新建', description: '填写并提交仓储转运申请', permission: 'merchant:overseas-warehouse:transfer:create', keepAlive: false },
},
{
path: 'overseas-warehouse/cargo-disposal/create', name: 'overseas-cargo-disposal-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', createPage: true }, release: 'active',
meta: { title: '新增货物废弃申请', workspaceTitle: '货物废弃 · 新建', description: '填写并提交货物废弃申请', permission: 'merchant:overseas-warehouse:discard:create', keepAlive: false },
},
{ {
path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create', path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active', component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active',
@@ -36,27 +36,3 @@ test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细',
assert.equal(result.cartonSelectionSupported, false) assert.equal(result.cartonSelectionSupported, false)
assert.equal(result.sources.length, 2) assert.equal(result.sources.length, 2)
}) })
test('接口 Long 字符串按数值相加,不串联数字', () => {
const source = { ...returned('1'), sourceType: 'INVENTORY', cartonSelectionSupported: false }
const sources = ['50', '100', '97'].map((availablePackages, index) => ({ ...source, orderId: `inventory:${index}`, returnCartonId: undefined, availablePackages }))
assert.equal(groupDiscardDocuments(sources)[0].availablePackages, 247)
assert.equal(groupDiscardDocuments([{ ...source, orderId: 'i:1', returnCartonId: undefined, availablePackages: '125' }, { ...source, orderId: 'i:2', returnCartonId: undefined, availablePackages: '125' }])[0].availablePackages, 250)
})
test('相同仓位重复关联只计一次,零值与未知可用量不进入可选来源', () => {
const source = { ...returned('1'), returnCartonId: undefined, sourceType: 'INVENTORY', inventoryPositionId: 'position:1', cartonSelectionSupported: false, availablePackages: 2 }
assert.equal(groupDiscardDocuments([source, source])[0].availablePackages, 2)
for (const availablePackages of [0, null, undefined, 'invalid']) assert.equal(groupDiscardDocuments([{ ...source, availablePackages }]).length, 0)
})
test('相同来源的混箱分片保留全部SKU和实箱,且不修改接口原始对象', () => {
const a = { ...returned('1'), sourceType: 'INVENTORY', inventoryPositionId: 'P', skus: [{ skuCode: 'A', availablePackages: 1 }] }
const b = { ...a, skus: [{ skuCode: 'B', availablePackages: 1 }], cartons: [{ ...a.cartons[0], skus: [{ skuCode: 'B', availablePackages: 1 }] }] }
const doc = groupDiscardDocuments([a, b])[0]
assert.equal(doc.availablePackages, 1)
assert.equal(doc.sources.length, 1)
assert.deepEqual(doc.sources[0].skus.map(sku => sku.skuCode), ['A', 'B'])
assert.deepEqual(doc.sources[0].cartons[0].skus.map(sku => sku.skuCode), ['A', 'B'])
assert.equal(a.skus.length, 1)
})
@@ -7,28 +7,7 @@ export interface DiscardDocument extends DiscardCandidate {
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */ /** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] { export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
const groups = new Map<string, DiscardDocument>() const groups = new Map<string, DiscardDocument>()
const identities = new Map<string, DiscardCandidate>()
for (const candidate of candidates) { for (const candidate of candidates) {
const available = Number(candidate.availablePackages)
if (candidate.availablePackages == null || !Number.isFinite(available) || available <= 0) continue
// 按真实来源身份去重;缺身份的旧接口行仍独立保留,不合并不同库存。
const identity = candidate.sourceType === 'RETURN_CARTON' ? candidate.returnCartonId : candidate.inventoryPositionId || candidate.orderId
const sourceKey = identity ? JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, identity]) : null
const existing = sourceKey ? identities.get(sourceKey) : undefined
if (existing) {
// 同一仓位的分片响应合并 SKU 和箱,重复关联不会丢掉箱内商品。
existing.skus = [...new Map([...existing.skus, ...candidate.skus].map(sku => [sku.skuCode, sku])).values()]
const cartons = new Map((existing.cartons || []).map(box => [box.cartonId, box]))
for (const box of candidate.cartons || []) {
const old = cartons.get(box.cartonId)
cartons.set(box.cartonId, old ? { ...old, skus: [...new Map([...old.skus, ...box.skus].map(sku => [sku.skuCode, sku])).values()] } : { ...box })
}
existing.cartons = [...cartons.values()]
existing.availablePackages = Math.max(Number(existing.availablePackages), available)
continue
}
const source = { ...candidate, skus: [...candidate.skus], cartons: candidate.cartons?.map(box => ({ ...box, skus: [...box.skus] })) }
if (sourceKey) identities.set(sourceKey, source)
const documentNo = candidate.inboundOrderNo || candidate.inboundCode const documentNo = candidate.inboundOrderNo || candidate.inboundCode
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId]) const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
let document = groups.get(key) let document = groups.get(key)
@@ -36,7 +15,7 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo
document = { ...candidate, orderId: key, sources: [], availablePackages: 0 } document = { ...candidate, orderId: key, sources: [], availablePackages: 0 }
groups.set(key, document) groups.set(key, document)
} }
document.sources.push(source) document.sources.push(candidate)
} }
for (const document of groups.values()) { for (const document of groups.values()) {
const boxes = new Set<string>() const boxes = new Set<string>()
@@ -48,9 +27,7 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo
} else if (source.cartonSelectionSupported && source.cartons?.length) { } else if (source.cartonSelectionSupported && source.cartons?.length) {
for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`) for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`)
} else { } else {
// Long 序列化可能返回字符串,必须先转数值再汇总,避免拼成 005010097。 untrackedPackages += source.availablePackages
const packages = Number(source.availablePackages)
untrackedPackages += Number.isFinite(packages) && packages > 0 ? packages : 0
} }
} }
document.availablePackages = boxes.size + untrackedPackages document.availablePackages = boxes.size + untrackedPackages
@@ -20,14 +20,6 @@ test('签收箱号只包含箱子和托盘签收,包裹实物记录不重复
assert.deepEqual(exports.receiptBoxDetails(receipts.slice(0, 1), cartons), []) assert.deepEqual(exports.receiptBoxDetails(receipts.slice(0, 1), cartons), [])
}) })
test('零箱、未记录箱数和包裹不补造一箱,缺少箱数时可使用真实标签', () => {
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'BOX', boxCount: 0, labels: [] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PARCEL', boxCount: 1, labels: [{}] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [{}, {}] }]), 2)
assert.equal(exports.receiptPrintableBoxCount([{ boxCount: -1 }, { boxCount: 'bad' }]), 0)
})
test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => { test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => {
for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) { for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) {
assert.equal(exports.formatReturnVolume(input), expected) assert.equal(exports.formatReturnVolume(input), expected)
@@ -1,12 +1,7 @@
import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders' import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders'
export function receiptPrintableBoxCount(receipts: ReturnReceipt[]) { export function receiptPrintableBoxCount(receipts: ReturnReceipt[]) {
// 只累计真实箱数;缺少箱数时使用已生成标签,不凭空补一箱。 return receipts.reduce((total, receipt) => total + Number(receipt.labels?.length || receipt.boxCount || 1), 0)
return receipts.reduce((total, receipt) => {
if (receipt.unitType === 'PARCEL') return total
const count = Number(receipt.boxCount ?? receipt.labels?.length ?? 0)
return total + (Number.isSafeInteger(count) && count >= 0 ? count : 0)
}, 0)
} }
export function receiptPhysicalDetails(pallets: ReturnPallet[], boxes: ReturnReceiptCarton[]) { export function receiptPhysicalDetails(pallets: ReturnPallet[], boxes: ReturnReceiptCarton[]) {
@@ -6,45 +6,6 @@ import ts from 'typescript'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
function listHarness(list, summary) {
const state = { query: { page: 1, size: 1, skuCode: 'FILTER' }, loading: { value: false }, listError: { value: '' }, statistics: { value: undefined }, rows: { value: [] }, total: { value: 0 } }
const source = view.slice(view.indexOf('async function load()'), view.indexOf('\nfunction changePage('))
const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const load = new Function(...Object.keys(state), 'listReturnOrders', 'getReturnOrderStatistics', `let listRequest=0; ${js}; return load`)(...Object.values(state), list, summary)
return { ...state, load }
}
test('全筛选统计使用服务器总计,分页行只按单号映射载体,不重算SKU', async () => {
const calls = []
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5 }
const h = listHarness(async p => { calls.push(p); return { total: '3', records: [{ returnNo: 'RT-1', receivedQuantity: 8 }] } }, async p => { calls.push(p); return { totals, pageDimensions: [{ returnNo: 'RT-1', receivedParcelCount: 1, receivedBoxCount: 1 }] } })
await h.load()
assert.deepEqual(h.statistics.value, totals)
assert.equal(h.rows.value[0].receivedParcelCount, 1)
h.query.page = 2
await h.load()
assert.equal(h.statistics.value.receivedQuantity, 14)
assert.deepEqual(calls[2], calls[3])
assert.equal(calls[3].page, 2)
})
test('统计失败清空旧列表和汇总,并忽略较旧查询的迟到结果', async () => {
let fail = false, finish
const h = listHarness(async () => ({ total: 1, records: [{ returnNo: 'RT-1' }] }), async p => {
if (fail) throw Error('统计服务不可用')
if (p.page === 1) return new Promise(resolve => { finish = resolve })
return { totals: { orderCount: 2 }, pageDimensions: [] }
})
const old = h.load(); h.query.page = 2; await h.load()
finish({ totals: { orderCount: 999 }, pageDimensions: [] }); await old
assert.equal(h.statistics.value.orderCount, 2)
fail = true; await h.load()
assert.equal(h.statistics.value, undefined)
assert.deepEqual(h.rows.value, [])
assert.equal(h.listError.value, '统计服务不可用')
assert.equal(h.loading.value, false)
})
function submitHarness(failure) { function submitHarness(failure) {
const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType(')) const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType('))
const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] } const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] }
@@ -116,10 +77,11 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/) assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
}) })
test('新建预报铺满工作区,基础字段等宽对齐且数量输入保持紧凑', () => { test('新建预报铺满工作区,联系字段与数量输入保持紧凑', () => {
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/) assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/)
assert.match(view, /<el-form label-width="130px" class="return-create-form">/) assert.match(view, /<el-form label-width="130px" class="return-create-form">/)
assert.match(view, /return-create-form :deep\(\.el-input\), \.return-create-body \.return-create-form :deep\(\.el-select\) \{ width:100%; max-width:none;/) assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
assert.match(view, /:deep\(\.el-table \.el-input-number\) \{ width:120px;/) assert.match(view, /:deep\(\.el-table \.el-input-number\) \{ width:120px;/)
assert.match(view, /<el-table class="return-forecast-table"[^>]*>/) assert.match(view, /<el-table class="return-forecast-table"[^>]*>/)
assert.match(view, /return-forecast-table \{[^}]*border-radius:8px;/) assert.match(view, /return-forecast-table \{[^}]*border-radius:8px;/)
@@ -11,7 +11,6 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l
import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createIdempotencyKey } from '@/utils/idempotencyKey'
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders' import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue' import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/returnOrders'
import { getReturnInventoryLabel } from '../api/returnInventory' import { getReturnInventoryLabel } from '../api/returnInventory'
import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay' import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay'
@@ -41,9 +40,6 @@ const printableBoxCount = computed(() => receiptPrintableBoxCount(printableRecei
const photoUrls = reactive<Record<string, string>>({}) const photoUrls = reactive<Record<string, string>>({})
const rows = ref<ReturnOrderSummary[]>([]) const rows = ref<ReturnOrderSummary[]>([])
const total = ref(0) const total = ref(0)
const statistics = ref<ReturnOrderStatistics['totals']>()
const listError = ref('')
let listRequest = 0
const operationWarehouses = ref<MerchantWarehouse[]>([]) const operationWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehousesLoading = ref(false) const operationWarehousesLoading = ref(false)
const operationWarehousesError = ref('') const operationWarehousesError = ref('')
@@ -66,13 +62,8 @@ const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 220, fixed: 'left' }, { key: 'actions', label: '操作', slotName: 'actions', width: 220, fixed: 'left' },
{ prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 }, { prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 },
{ key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 }, { key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 },
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报(件)', width: 110 }, { prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收(件)', width: 110 }, { prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 150 },
{ prop: 'remainingQuantity', label: '待收(件)', width: 110 },
{ prop: 'receivedBoxCount', label: '签收箱数', width: 110 },
{ prop: 'receivedParcelCount', label: '签收包裹数', width: 115 },
{ prop: 'receivedPalletCount', label: '签收托数', width: 110 },
{ key: 'status', label: '状态', slotName: 'status', width: 150 },
{ key: 'relabelApplications', label: '换标子单', slotName: 'relabelApplications', minWidth: 340 }, { key: 'relabelApplications', label: '换标子单', slotName: 'relabelApplications', minWidth: 340 },
{ key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 }, { key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
] ]
@@ -156,21 +147,9 @@ async function openEdit(returnNo: string) {
function addItem() { form.items.push(emptySku()) } function addItem() { form.items.push(emptySku()) }
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) } function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
async function load() { async function load() {
const request = ++listRequest
loading.value = true loading.value = true
listError.value = ''; statistics.value = undefined try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = Number(response.total); if (!Number.isFinite(total.value)) total.value = response.records.length }
try { finally { loading.value = false }
const params = { ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }
const [response, summary] = await Promise.all([listReturnOrders(params), getReturnOrderStatistics(params)])
if (request !== listRequest) return
const dimensions = new Map(summary.pageDimensions.map(row => [row.returnNo, row]))
rows.value = response.records.map(row => ({ ...row, ...dimensions.get(row.returnNo) }))
total.value = Number(response.total); statistics.value = summary.totals
} catch (error) {
if (request !== listRequest) return
rows.value = []; total.value = 0
listError.value = error instanceof Error ? error.message : '退货列表及汇总加载失败'
} finally { if (request === listRequest) loading.value = false }
} }
function changePage(page: number) { function changePage(page: number) {
if (page === query.page) return if (page === query.page) return
@@ -344,11 +323,6 @@ onBeforeUnmount(clearPhotoUrls)
<template v-else-if="!props.detailPage"> <template v-else-if="!props.detailPage">
<QueryToolbar v-model="queryModel" class="return-query" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" /> <QueryToolbar v-model="queryModel" class="return-query" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
<el-alert v-if="listError" type="error" :closable="false" :title="listError"><el-button @click="load">重新加载</el-button></el-alert>
<div v-if="statistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuTypes }} 种 SKU · 预报 {{ statistics.expectedQuantity }} 件 · 实收 {{ statistics.receivedQuantity }} 件 · 待收 {{ statistics.remainingQuantity }} 件 · 签收 {{ statistics.receivedBoxCount }} 箱 / {{ statistics.receivedParcelCount }} 包裹 / {{ statistics.receivedPalletCount }} 托
<span>预报箱/包裹/托数:未提供</span>
</div>
<div class="return-table-card"> <div class="return-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报"> <DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template>
@@ -390,7 +364,7 @@ onBeforeUnmount(clearPhotoUrls)
<section class="return-detail-section"> <section class="return-detail-section">
<h3>签收概览</h3> <h3>签收概览</h3>
<div class="return-detail-grid"> <div class="return-detail-grid">
<div><span>预报(件)</span><strong>{{ selected.order.expectedQuantity }}</strong></div><div><span>实收(件)</span><strong>{{ selected.order.receivedQuantity }}</strong></div><div><span>待收(件)</span><strong>{{ Math.max(selected.order.expectedQuantity - selected.order.receivedQuantity, 0) }}</strong></div> <div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div>
<div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div> <div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div>
<div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div> <div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div>
<div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div> <div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div>
@@ -501,8 +475,6 @@ onBeforeUnmount(clearPhotoUrls)
</template> </template>
<style scoped> <style scoped>
.return-filter-summary { padding:12px 16px; margin-bottom:12px; border:1px solid var(--client-border); border-radius:8px; background:var(--client-surface,#fff); line-height:1.8; }
.return-filter-summary span { margin-left:16px; color:var(--client-text-secondary,#64748b); }
.return-query.query-toolbar--grid { display:grid; grid-template-columns:repeat(4,minmax(0,280px)) max-content; gap:14px 18px; align-items:end; } .return-query.query-toolbar--grid { display:grid; grid-template-columns:repeat(4,minmax(0,280px)) max-content; gap:14px 18px; align-items:end; }
.return-query :deep(.query-toolbar__filters) { display:contents; } .return-query :deep(.query-toolbar__filters) { display:contents; }
.return-query :deep(.query-toolbar__field) { display:flex; flex-direction:column; align-items:stretch; gap:6px; } .return-query :deep(.query-toolbar__field) { display:flex; flex-direction:column; align-items:stretch; gap:6px; }
@@ -512,15 +484,16 @@ onBeforeUnmount(clearPhotoUrls)
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; } .relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; } .return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; } .return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-create-header, .return-create-body, .return-create-actions { width:100%; min-width:0; box-sizing:border-box; } .return-create-header, .return-create-body, .return-create-actions { width:calc(100% - 24px); max-width:1118px; margin-left:24px; box-sizing:border-box; }
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); } .return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; } .return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
.return-create-body { padding:18px 20px 14px; } .return-create-body { padding:18px 20px 14px; }
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; } .return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
.return-create-body .return-create-form { width:100%; column-gap:24px; } .return-create-body .return-create-form { max-width:900px; column-gap:24px; }
.return-create-body .return-create-form :deep(.el-form-item) { margin-bottom:16px; } .return-create-body .return-create-form :deep(.el-form-item) { margin-bottom:16px; }
.return-create-body .return-create-form :deep(.el-form-item__content) { min-width:0; } .return-create-body .return-create-form :deep(.el-form-item__content) { min-width:0; }
.return-create-body .return-create-form :deep(.el-input), .return-create-body .return-create-form :deep(.el-select) { width:100%; max-width:none; } .return-create-body .return-create-form :deep(.el-input) { max-width:320px; }
.return-create-body .return-create-form :deep(.el-select) { max-width:520px; }
.return-create-body .return-create-form :deep(.el-textarea) { max-width:100%; } .return-create-body .return-create-form :deep(.el-textarea) { max-width:100%; }
.return-create-body :deep(.el-table .el-input-number) { width:120px; } .return-create-body :deep(.el-table .el-input-number) { width:120px; }
.return-forecast-table { overflow:hidden; border:1px solid var(--client-border); border-radius:8px; --el-table-header-bg-color:#f5f7fb; --el-table-header-text-color:#526176; --el-table-border-color:#edf1f5; --el-table-row-hover-bg-color:#f8faff; } .return-forecast-table { overflow:hidden; border:1px solid var(--client-border); border-radius:8px; --el-table-header-bg-color:#f5f7fb; --el-table-header-text-color:#526176; --el-table-border-color:#edf1f5; --el-table-row-hover-bg-color:#f8faff; }
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@ test('废弃来源包含通用仓位,选择后带出全部库存且不把选
function selectionRows(boxes, selectedKeys) { function selectionRows(boxes, selectedKeys) {
const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()') const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()')
const rows = { value: [] } const rows = { value: [] }
new Function('discardSourceBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({})) new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
return rows.value return rows.value
} }
test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => { test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => {
@@ -48,12 +48,3 @@ test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU',
const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2']) const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2'])
assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined)) assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined))
}) })
test('通用混箱同仓位保留全部SKU,跨仓位相同SKU保持独立来源', () => {
const skus = [{ skuCode: 'A', availablePackages: 1 }, { skuCode: 'B', availablePackages: 1 }]
const source = { orderId: null, inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 1, skus }
const boxes = [{ key: 'INVENTORY:1', cartonId: '1', source, skus }, { key: 'INVENTORY:2', cartonId: '2', source: { ...source, inventoryPositionId: '43' }, skus: [skus[0]] }]
const rows = selectionRows(boxes, ['INVENTORY:1', 'INVENTORY:2'])
assert.equal(rows.length, 3)
assert.deepEqual(rows.map(row => [row.sourceInventoryPositionId, row.sku, row.quantity, row.discardCartonIds]), [['42', 'A', 1, ['1']], ['42', 'B', 1, ['1']], ['43', 'A', 1, ['2']]])
})
@@ -19,11 +19,3 @@ test('mixed carton detail and pallet summaries preserve every SKU without duplic
assert.equal(display.receivedItemQuantity('2'), 7) assert.equal(display.receivedItemQuantity('2'), 7)
assert.equal(display.receivedItemCartons('2').length, 1) assert.equal(display.receivedItemCartons('2').length, 1)
}) })
test('discard and shipment lifecycle keeps historical receipt quantities', () => {
const detail = { value: { items: [{ id: '1', skuCode: 'A' }], cartons: ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].map((status, i) => ({ status, transferItemId: '1', transferPalletId: '9', quantity: i + 1 })) } }
const display = load(detail)
assert.equal(display.receivedItemCartons('1').length, 4)
assert.equal(display.receivedItemQuantity('1'), 10)
assert.equal(display.palletQuantity({ id: '9' }), 10)
})
@@ -371,13 +371,13 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
assert.match(view, /v-model="row.expectedPackageCount"/) assert.match(view, /v-model="row.expectedPackageCount"/)
assert.match(view, /Number.isSafeInteger\(row.quantity\)/) assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/) assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
assert.match(view, /\['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'\]\.includes\(carton.status\)/) assert.match(view, /carton.status === 'COUNTED'/)
assert.match(view, /实际签收箱数/) assert.match(view, /实际签收箱数/)
}) })
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => { test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label) for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
assert.match(view, /countedTransferCartons\(\)\.filter\(\(carton\) => pallet.id != null/) assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/) assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/) assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
assert.match(view, /@click="showTransferCartons = true"/) assert.match(view, /@click="showTransferCartons = true"/)
-24
View File
@@ -1,24 +0,0 @@
# Client 系统页模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/system/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 只有 `views/ForbiddenView.vue`(403 无权限页)与 `routes.ts`;不承载业务。
## 页面与入口
| 目标 | 页面/入口 | 说明 |
|---|---|---|
| 无权限提示页 | `views/ForbiddenView.vue` | 路由守卫或接口 403 后跳转 |
| 系统页路由 | `routes.ts` | 由 `../../router/index.ts` 装配 |
## 关键边界
- 403 页只做提示与返回入口,不暴露权限码、内部角色或接口细节。
- 不在此模块新增业务页面或全局状态。
- 权限判断仍在服务端与 `core/access`,页面不补前端鉴权分支。
## 验证
- 定向:`pnpm test src/modules/system/<目标测试>`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
-30
View File
@@ -1,30 +0,0 @@
# Client 企业设置模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/tenant-management/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,层级与权限语义见 `../../../docs/平台客户层级与权限契约.md`。
- 本模块承载企业资料、成员、角色与权限(`company/profile`、`organization/*`);账号与登录标识归 `backend auth/`,接入侧实现归 `backend iam/`。
- 模块对外只暴露 `public.ts` 与 `contracts.ts`,其他模块不得穿透内部 `views/`、`store/`。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 企业资料、资质上传与状态 | `views/CompanyProfileView.vue`、`utils/qualificationFile.ts`、`utils/qualificationStatus.ts`;`company/profile` | `api/merchantIam.ts`;`iam/`、`file/` |
| 企业成员管理 | `views/MembersView.vue`;`organization/members` | 页面经 `store/tenantAdministration.ts` → `api/merchantIam.ts`;`iam/`、`auth/` |
| 企业角色 | `views/RolesView.vue`;`organization/roles` | 页面经 `store/tenantAdministration.ts` → `api/merchantIam.ts`;`iam/` |
| 角色权限分配 | `views/PermissionsView.vue`;`organization/permissions` | 页面经 `store/tenantAdministration.ts` → `api/merchantIam.ts`;权限码常量见 `contracts.ts` |
| 跨页状态与稳定契约 | `store/tenantAdministration.ts`、`contracts.ts`、`public.ts` | 服务端 DTO |
## 关键边界
- 企业资料只修改 `contactPhone` 等企业联系信息,不提交或回填成员登录手机号;两者独立维护。
- 成员登录手机号可选;修改时由 IAM/Auth 同步并撤销该账号现有会话,前端不互相回填或联动提交。
- 审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可在已有营业执照后单独补传或替换。
- 企业成员、角色与权限只能管理当前企业范围;前端隐藏不是安全控制,服务端必须重复校验租户与权限。
- 认证与会话统一经 `../../api/http.ts`、`../../api/session.ts`;页面不读写 Token,不自行处理 401 刷新。
## 验证
- 定向:`pnpm test src/modules/tenant-management/<目标测试>`;成员或权限字段变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
+19 -7
View File
@@ -52,7 +52,7 @@ test('keeps standalone business pages at the client sidebar root', () => {
{ title: '首页', children: undefined }, { title: '首页', children: undefined },
{ {
title: '订单中心', title: '订单中心',
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'], children: ['头程订单', '入库订单', '派送订单', '海外采购', '美国本土卡', '美本土散板'],
}, },
{ title: '报关资料', children: undefined }, { title: '报关资料', children: undefined },
{ title: '船期价格', children: undefined }, { title: '船期价格', children: undefined },
@@ -88,6 +88,8 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [ assert.deepEqual(paths, [
'/', '/',
'/orders/first-leg', '/orders/first-leg',
'/orders/inbound',
'/orders/delivery',
'/cross-border/procurement', '/cross-border/procurement',
'/cross-border/domestic-trucking', '/cross-border/domestic-trucking',
'/cross-border/pallet-shipping', '/cross-border/pallet-shipping',
@@ -119,10 +121,22 @@ test('filters inaccessible leaves and empty navigation groups', () => {
assert.equal(getFirstAccessiblePath([]), '/forbidden') assert.equal(getFirstAccessiblePath([]), '/forbidden')
}) })
test('omits inbound and delivery entries from the customer sidebar', () => { test('exposes active inbound and delivery queries only with their existing read permissions', () => {
const permissions = ['merchant:warehouse:delivery:list', 'merchant:warehouse:inbound:list'] const deliveryOnly = ['merchant:warehouse:delivery:list']
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions)), []) const inboundOnly = ['merchant:warehouse:inbound:list']
assert.equal(getFirstAccessiblePath(permissions), '/forbidden') assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, deliveryOnly))
.map(({ title, path }) => ({ title, path })), [{ title: '派送订单', path: '/orders/delivery' }])
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, inboundOnly))
.map(({ title, path }) => ({ title, path })), [{ title: '入库订单', path: '/orders/inbound' }])
assert.equal(getFirstAccessiblePath(deliveryOnly), '/orders/delivery')
assert.equal(getFirstAccessiblePath(inboundOnly), '/orders/inbound')
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation,
['merchant:warehouse:delivery:create'])), [])
assert.deepEqual(getRouteBreadcrumbs('/orders/delivery/DL-1', '派送单详情'), [
{ title: '首页', path: '/' }, { title: '订单中心' },
{ title: '派送订单', path: '/orders/delivery' }, { title: '派送单详情' },
])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/delivery'), ['order-center'])
}) })
test('excludes entries marked for manual hiding without affecting other visible items', () => { test('excludes entries marked for manual hiding without affecting other visible items', () => {
@@ -369,8 +383,6 @@ test('exposes customer business lists and keeps utility routes outside navigatio
] ]
const hiddenActivePaths = new Set([ const hiddenActivePaths = new Set([
'orders/inbound',
'orders/delivery',
'first-leg/quick-order', 'first-leg/quick-order',
'overseas-warehouse/return-inventory', 'overseas-warehouse/return-inventory',
'overseas-warehouse/return-cartons', 'overseas-warehouse/return-cartons',
+2
View File
@@ -52,6 +52,8 @@ export const clientNavigation: ClientNavigationNode[] = [
icon: ClipboardList, icon: ClipboardList,
children: [ children: [
requireLeaf('first-leg-orders', '头程订单'), requireLeaf('first-leg-orders', '头程订单'),
requireLeaf('orders-inbound', '入库订单'),
requireLeaf('orders-delivery', '派送订单'),
requireLeaf('cross-border-procurement'), requireLeaf('cross-border-procurement'),
requireLeaf('cross-border-domestic-trucking'), requireLeaf('cross-border-domestic-trucking'),
requireLeaf('cross-border-pallet-shipping'), requireLeaf('cross-border-pallet-shipping'),