diff --git a/AGENTS.md b/AGENTS.md index 2e51930..36f4458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,25 +39,25 @@ ## 模块快速查询 -退货预报与签收结果:`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/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。 +本表只保留首个入口;完整 API、字段、状态与恢复规则见 [按需业务索引](docs/business-index.md)。从本应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md` 读取命中条款;涉及业务行为变更时必须核对对应细则,纯定位不全文加载。保留所有适用的最近模块指引。 -先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。 +表中路径均相对本应用根目录。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。 -| 业务 | 模块入口 | 联查模块或服务端 | -|---|---|---| -| 登录、注册与会话 | `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 | +| 业务 | 首个入口 | +|---|---| +| 登录、注册与会话 | `src/modules/auth/AGENTS.md`、`src/api/session.ts`、`src/api/http.ts` | +| 企业资料、成员与角色 | `src/modules/tenant-management/AGENTS.md`、`src/modules/account/AGENTS.md` | +| 工作台与系统入口 | `src/modules/dashboard/AGENTS.md`、`src/modules/system/AGENTS.md` | +| 企业成员消息收件箱 | `src/modules/notifications/AGENTS.md` | +| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/AGENTS.md` | +| 装柜完成后的退税报关资料 | `src/modules/first-leg/AGENTS.md`、`src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 统一订单展示 | `src/modules/order-management/AGENTS.md` | +| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/AGENTS.md` | +| 客户资金账户、授信与充值申请 | `src/modules/finance/AGENTS.md` | +| 海外采购 | `src/modules/cross-border/AGENTS.md` | +| 美国本土卡与美本土散板 | `src/modules/cross-border/AGENTS.md` | +| 海外仓入库订单 | `src/modules/order-management/AGENTS.md` | +| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/AGENTS.md` | ## 租户、认证与数据安全 @@ -97,8 +97,8 @@ pnpm typecheck pnpm build ``` -- 日常变更至少运行 `pnpm typecheck`;交付前运行 `pnpm build`。 -- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。 +- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,小逻辑跑定向测试;类型/签名变化加最小类型检查,公共能力覆盖实际消费者。不因源码修改或交付自动 typecheck、全量测试或构建;构建和全量测试分别说明触发原因,相同编译不重复跑。纯文档/索引只检查路径、链接及索引合同。 +- 当前测试使用 Node.js 内置测试运行器,局部改动指定受影响测试文件,`pnpm test` 是全量入口;项目未声明 lint 脚本,不得声称 lint 已通过。 - 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。 ## 全量验收脚本 @@ -110,11 +110,4 @@ pnpm build ## 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` 定位。 +全量验收的两类客户可见范围按需查 `docs/business-index.md` 同名节;平台/PDA 操作与内部证据不得因此暴露给客户。普通修改保持最小模块范围,不自动加载验收资料。 diff --git a/docs/business-index.md b/docs/business-index.md new file mode 100644 index 0000000..44898eb --- /dev/null +++ b/docs/business-index.md @@ -0,0 +1,40 @@ +# 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`。列表“确认计费重”仅在具备确认权限且服务端 `customerFeeConfirmationRequired=true` 时显示,已确认或未推送费用时隐藏;详情可查看费用;确认提交 `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` 定位。 diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index dde7db9..67e3bd8 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -43,6 +43,8 @@ const visibleNavigation = computed(() => filterNavigationByPermissions(clientNav const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, props.activePath))) const openedKeys = ref>(new Set(activeParentKeys.value)) +// Expand the order group by default so the three customer order shortcuts are visible. +openedKeys.value = new Set([...openedKeys.value, 'order-center']) function handleToggle(key: string) { const next = new Set(openedKeys.value) diff --git a/src/modules/account/AGENTS.md b/src/modules/account/AGENTS.md new file mode 100644 index 0000000..54c46b1 --- /dev/null +++ b/src/modules/account/AGENTS.md @@ -0,0 +1,26 @@ +# 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`。 diff --git a/src/modules/auth/AGENTS.md b/src/modules/auth/AGENTS.md new file mode 100644 index 0000000..abaec55 --- /dev/null +++ b/src/modules/auth/AGENTS.md @@ -0,0 +1,30 @@ +# 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`。 diff --git a/src/modules/cross-border/AGENTS.md b/src/modules/cross-border/AGENTS.md new file mode 100644 index 0000000..0a296a9 --- /dev/null +++ b/src/modules/cross-border/AGENTS.md @@ -0,0 +1,30 @@ +# 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`。 diff --git a/src/modules/cross-border/PROTOTYPE_MATRIX.md b/src/modules/cross-border/PROTOTYPE_MATRIX.md new file mode 100644 index 0000000..0742710 --- /dev/null +++ b/src/modules/cross-border/PROTOTYPE_MATRIX.md @@ -0,0 +1,34 @@ +# 本土卡车字段调整核对 + +依据:用户提供的下单页截图 `codex-clipboard-c458c2e1-e9a8-4726-a8cd-4581e5e7102f.png` 及本轮字段、必填和时分确认。当前需求规则见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。 + +| 页面 | 操作 | 依据及承载 | 当前调整 | 验收 | +|---|---|---|---|---| +| 美国本土卡 | 新增 | 用户截图:独立页面、单列表单、取消和提交按钮 | 保留容器、按钮;按确认删除字段,新增车型及两端联系人和时间 | 自动验证与人工视觉验收分开;待人工截图确认 | +| 美国本土卡 | 列表 | 现有 `BusinessOrdersView.vue` 的独立列表与左侧操作列 | 对应列展示车型和取送时间 | 待人工截图确认 | +| 美国本土卡 | 详情 | 现有独立详情路由及键值网格 | 展示保存的两端联系信息、车型和取送时间;加宽长字段标签 | 待人工截图确认 | + +本节记录本土卡车调整;散板最新范围见下节。无新增审核、删除或附件操作。 + + +## 本土散板简化需求合同 + +当前字段规则见 `docs/海外仓业务/README.md`“本土散板简化下单”;依据用户提供的 Admin 简化原型及本轮同步消费者确认,不改变客户认证范围。 + +| 页面 | 操作 | 承载与范围 | 验收 | +|---|---|---|---| +| 美本土散板 | 新增 | 保持现有独立路由和表单容器;同步商品、预计托数、四项联系信息及必填的当地取送时间;删除运输方式、柜型和下单安排项 | 自动测试与人工视觉分开;待人工截图确认 | +| 美本土散板 | 列表、详情 | 保持现有独立路由;展示同一保存值和真实承运安排,不访问运力池 | 待人工截图确认 | + + +## Admin / Client 统一运输下单表单(2026-10-19) + +依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。 + +| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 | +|---|---|---|---|---| +| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 | +| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 | +| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 | + +统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。 diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index ae51b30..b54b2de 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -17,7 +17,9 @@ export interface BusinessOrderQuery { status?: string routeKeyword?: string transportType?: string - transportMode?: string + vehicleType?: 'PALLET_15' | 'PALLET_30' + pickupFrom?: string + pickupTo?: string requestedFrom?: string requestedTo?: string createdFrom?: string @@ -27,21 +29,25 @@ export interface BusinessOrderQuery { export interface TransportSummary { fleetArrangement: import('../fleetArrangement').FleetArrangement | null orderNo: string - transportType: string - transportMode?: string + transportType?: string palletRequired?: string | null origin: string destination: string - containerType: string | null palletCount?: number packageCount: number totalWeightKg: number totalVolumeCbm: number budgetAmount: number | null currency: string | null - contactName: string | null - contactInfo: string | null - requestedDate: string | null + productName?: string | null + estimatedPalletCount?: number | null + vehicleType?: 'PALLET_15' | 'PALLET_30' | null + originContactName?: string | null + originContactPhone?: string | null + destinationContactName?: string | null + destinationContactPhone?: string | null + pickupAt?: string | null + deliveryAt?: string | null status: string version: number createdAt: string @@ -56,23 +62,44 @@ export interface TransportDetail { } export interface TransportCreate { - transportType: string - transportMode?: string - palletRequired?: string + productName?: string + estimatedPalletCount: number origin: string originAddress?: string destination: string destinationAddress?: string - containerType?: string - palletCount?: number packageCount: number totalWeightKg: number totalVolumeCbm: number budgetAmount?: number currency?: string - contactName?: string - contactInfo?: string - requestedDate?: string + originContactName: string + originContactPhone: string + destinationContactName: string + destinationContactPhone: string + pickupAt: string + deliveryAt: string + remark?: string + idempotencyKey: string +} + +export interface TruckCreate { + vehicleType: 'PALLET_15' | 'PALLET_30' + origin: string + originAddress?: string + destination: string + destinationAddress?: string + originContactName: string + originContactPhone: string + destinationContactName: string + destinationContactPhone: string + pickupAt: string + deliveryAt: string + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount?: number + currency?: string remark?: string idempotencyKey: string } @@ -81,6 +108,10 @@ export interface TransportUpdate extends Omit version: number } +export interface TruckUpdate extends Omit { + version: number +} + const endpointByKind: Record = { trucking: '/merchant/v1/last-leg/us-truck-orders', looseboard: '/merchant/v1/last-leg/local-looseboard-orders', @@ -98,11 +129,15 @@ export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: stri return apiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) } -export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { +export function createTransport(kind: 'trucking', payload: TruckCreate): Promise +export function createTransport(kind: 'looseboard', payload: TransportCreate): Promise +export function createTransport(kind: CrossBorderBusinessKind, payload: TransportCreate | TruckCreate) { return apiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) } -export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) { +export function updateTransport(kind: 'trucking', orderNo: string, payload: TruckUpdate): Promise +export function updateTransport(kind: 'looseboard', orderNo: string, payload: TransportUpdate): Promise +export function updateTransport(kind: CrossBorderBusinessKind, orderNo: string, payload: TransportUpdate | TruckUpdate) { return apiRequest({ method: kind === 'trucking' ? 'PATCH' : 'PUT', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`, diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 38830ef..6279cc0 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -7,6 +7,9 @@ import ts from 'typescript' const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +const demandComponent = readFileSync(new URL('./components/TransportDemandForm.vue', import.meta.url), 'utf8') +const demandLogic = ts.transpileModule(readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '') +const demandHelpers = runInNewContext(`${demandLogic}\n({ createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand })`) const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8') const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') @@ -106,7 +109,7 @@ test('preserves the existing transport responsive rules without adding a narrow- assert.match(view, /queryFields/) assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/) assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/) - assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/) + assert.match(demandComponent, /\.transport-demand-budget \{ display: grid; width: 100%/) }) test('uses the shared dense order-list and detail visual language', () => { @@ -115,3 +118,93 @@ test('uses the shared dense order-list and detail visual language', () => { assert.match(view, /\.detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/) assert.match(view, /\.detail-grid div \{[^}]*min-height: 34px;[^}]*grid-template-columns: 76px minmax\(0, 1fr\)/) }) + +function transportHarness(kind = 'trucking', failFirst = false) { + const calls = [] + const script = view.match(/ + + + + diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index 8e8e0eb..d65d9c8 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -54,16 +54,8 @@ test('successful net debit excludes the original estimate and counts only succes assert.equal(presentation.feeStatus(fee('DUTY', null, 100)), '待核定') }) -test('request recovery preserves content and identity while malformed recovery cannot create a replacement', () => { - const original = presentation.buildProcurementCreate(draft(), 'Company', 'fixed-key') - assert.deepEqual(normalized(submission.restoreProcurementSubmission(JSON.stringify(original))), normalized(original)) - assert.throws(() => submission.restoreProcurementSubmission('{}')) - assert.throws(() => submission.restoreProcurementSubmission(JSON.stringify({ ...original, currency: 'EUR' }))) - assert.equal(submission.definitelyRejected({ code: 'TIMEOUT' }), false) - assert.equal(submission.definitelyRejected({ status: 500 }), false) - assert.equal(submission.definitelyRejected({ status: 409 }), false) - assert.equal(submission.definitelyRejected({ status: 400 }), true) - assert.equal(submission.procurementRequestKey({ getRandomValues: target => { target.set([1, 2, 3, 4]); return target } }), 'proc-00000001000000020000000300000004') +test('new requests get unique keys without restoring stored submissions', () => { + assert.notEqual(submission.procurementRequestKey(crypto), submission.procurementRequestKey(crypto)) }) class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } } @@ -109,18 +101,16 @@ test('detail has one in-flight request and an older response cannot overwrite an } finally { harness.dispose() } }) -test('timeout retries the exact original body after reload and does not accept changed form data', async () => { - const storage = new Map(), calls = [] - const first = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) +test('timeout releases the form and another click submits the edited current values', async () => { + const calls = [] + const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) try { - Object.assign(first.state.form, draft()); await first.state.submit(); assert.equal(calls.length, 1); assert.ok(first.state.pending.value) - first.state.form.productName = 'changed'; await first.state.submit(); assert.deepEqual(calls[1], calls[0]) - } finally { first.dispose() } - const recovered = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); return { order: { orderNo: 'PO-SAME' } } } } }) - try { - assert.equal(recovered.state.form.productName, 'Test product'); await recovered.state.submit(); assert.deepEqual(calls[2], calls[0]) - assert.equal(storage.size, 0); assert.equal(recovered.pushes[0].params.orderNo, 'PO-SAME') - } finally { recovered.dispose() } + Object.assign(harness.state.form, draft()); await harness.state.submit() + assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) + harness.state.form.productName = 'changed'; await harness.state.submit() + assert.equal(calls[1].productName, 'changed'); assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey) + assert.equal(harness.state.submitting.value, false) + } finally { harness.dispose() } }) test('submission double-click sends once and definitive validation rejection permits correction', async () => { @@ -129,7 +119,7 @@ test('submission double-click sends once and definitive validation rejection per try { Object.assign(harness.state.form, draft()); const first = harness.state.submit(); await harness.state.submit(); assert.equal(calls, 1) reject(new ApiError('invalid', 'A0422')); await first - assert.equal(harness.state.pending.value, null); assert.equal(harness.storage.size, 0) + assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) } finally { harness.dispose() } }) @@ -149,17 +139,18 @@ test('pagination batches one request and switching account clears old rows befor }) -test('replay rejection cannot discard an earlier unknown creation outcome', async () => { - const calls = []; let attempt = 0 - const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { - calls.push(normalized(payload)); attempt++ - if (attempt === 1) throw new ApiError('timeout', 'TIMEOUT') - throw new ApiError('permission revoked', 'A0403') - } } }) +test('consecutive timeout and permission failures never persist or freeze a submission', async () => { + let attempt = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: async () => { throw new ApiError('failed', ++attempt === 1 ? 'TIMEOUT' : 'A0403') } } }) try { - Object.assign(harness.state.form, draft()); await harness.state.submit() - const saved = [...harness.storage.values()][0]; await harness.state.submit() - assert.equal([...harness.storage.values()][0], saved); assert.ok(harness.state.pending.value) - assert.deepEqual(calls[1], calls[0]) + Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit() + assert.equal(attempt, 2); assert.equal(harness.state.submitting.value, false); assert.equal(harness.storage.size, 0) + harness.state.form.productName = 'editable'; assert.equal(harness.state.form.productName, 'editable') } 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.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/) +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts index 0e1e08c..0bc9ec7 100644 --- a/src/modules/cross-border/procurementPresentation.ts +++ b/src/modules/cross-border/procurementPresentation.ts @@ -101,11 +101,12 @@ export function draftBudgetTotal(draft: ProcurementDraft) { } export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate { for (const [field, label, max] of [ - ['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], + ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], ['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64], ] as const) { 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 (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数') const price = cents(draft.budgetUnitPrice) diff --git a/src/modules/cross-border/procurementSubmission.ts b/src/modules/cross-border/procurementSubmission.ts index 3c4e9c4..ef5d581 100644 --- a/src/modules/cross-border/procurementSubmission.ts +++ b/src/modules/cross-border/procurementSubmission.ts @@ -1,23 +1,3 @@ -import type { ProcurementCreate } from './api/procurement' -import { buildProcurementCreate, emptyProcurementDraft } from './procurementPresentation' - -/** Restore the exact request; an unknown result cannot become a new order. */ -export function restoreProcurementSubmission(raw: string): ProcurementCreate { - const saved: unknown = JSON.parse(raw) - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('提交记录格式错误') - const value = saved as Record - for (const key of ['customerName', 'purchaseCategory', 'productName', 'currency', 'receivingAddress', 'contactName', 'contactPhone', 'idempotencyKey']) if (typeof value[key] !== 'string') throw new Error('提交记录不完整') - if (typeof value.purchaseQuantity !== 'number' || typeof value.budgetUnitPrice !== 'number' || !String(value.idempotencyKey).trim() || String(value.idempotencyKey).length > 255) throw new Error('提交记录不完整') - for (const key of ['productSpecification', 'productUrl', 'remark']) if (value[key] !== undefined && typeof value[key] !== 'string') throw new Error('提交记录不完整') - if (value.currency !== 'CNY' && value.currency !== 'USD') throw new Error('提交记录币种无效') - return buildProcurementCreate({ ...emptyProcurementDraft(), purchaseCategory: String(value.purchaseCategory), productName: String(value.productName), - productSpecification: String(value.productSpecification || ''), productUrl: String(value.productUrl || ''), purchaseQuantity: String(value.purchaseQuantity), - budgetUnitPrice: String(value.budgetUnitPrice), currency: value.currency, receivingAddress: String(value.receivingAddress), contactName: String(value.contactName), - contactPhone: String(value.contactPhone), remark: String(value.remark || '') }, String(value.customerName), String(value.idempotencyKey)) -} -export function definitelyRejected(error: { code?: string; status?: number }) { - return ['A0400', 'A0401', 'A0403', 'A0422'].includes(error.code || '') || [400, 401, 403, 422].includes(error.status || 0) -} export function procurementRequestKey(provider: Pick & Partial> = globalThis.crypto) { if (provider.randomUUID) return provider.randomUUID() return `proc-${Array.from(provider.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('')}` diff --git a/src/modules/cross-border/transportDemandForm.test.mjs b/src/modules/cross-border/transportDemandForm.test.mjs new file mode 100644 index 0000000..15de2de --- /dev/null +++ b/src/modules/cross-border/transportDemandForm.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8') +const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '') +const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`) +const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' }) + +test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => { + const form = createTransportDemandForm() + for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(form[key], '') + for (const key of ['estimatedPalletCount', 'packageCount', 'totalWeightKg', 'totalVolumeCbm']) assert.equal(form[key], 0) + assert.equal(form.budgetAmount, undefined) + assert.equal(form.currency, 'USD') + assert.equal(buildTransportDemand('truck', valid()).budgetAmount, undefined) + assert.equal(buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }).budgetAmount, 0) +}) + +for (const kind of ['truck', 'looseboard']) { + test(`${kind} requires both route contacts and both service times before building a payload`, () => { + for (const field of ['origin', 'destination', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) { + const form = { ...valid(), [field]: '' } + assert.ok(transportDemandError(kind, form), field) + assert.throws(() => buildTransportDemand(kind, form), undefined, field) + } + }) + test(`${kind} rejects negative cargo, fractional counts, invalid budgets and reversed times`, () => { + for (const field of ['packageCount', 'totalWeightKg', 'totalVolumeCbm', 'budgetAmount']) { + assert.ok(transportDemandError(kind, { ...valid(), [field]: -1 }), field) + assert.ok(transportDemandError(kind, { ...valid(), [field]: Number.NaN }), field) + } + assert.ok(transportDemandError(kind, { ...valid(), packageCount: 1.5 })) + assert.ok(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:29:00' })) + assert.ok(transportDemandError(kind, { ...valid(), currency: 'EUR' })) + }) + test(`${kind} sends minute wall-clock values, trims contacts, and excludes customer and old fields`, () => { + const payload = buildTransportDemand(kind, { ...valid(), customerId: 'forged', tenantId: 'forged', transportMode: 'OLD', containerType: '40HQ' }) + assert.equal(payload.pickupAt, '2026-10-20T09:30:00') + assert.equal(payload.deliveryAt, '2026-10-20T15:45:00') + assert.equal(payload.origin, 'LA') + assert.equal(payload.originContactName, '发货人') + assert.equal(payload.destinationContactPhone, '+1-555-0101') + for (const key of ['customerId', 'tenantId', 'transportMode', 'transportType', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, key), false, key) + assert.equal(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:30:01' }), undefined) + }) +} + +test('truck accepts only the two vehicle types and does not submit looseboard cargo forecasts', () => { + assert.ok(transportDemandError('truck', { ...valid(), vehicleType: 'PALLET_99' })) + const truck = buildTransportDemand('truck', { ...valid(), productName: 'cargo', estimatedPalletCount: 3 }) + assert.equal(Object.hasOwn(truck, 'productName'), false) + assert.equal(Object.hasOwn(truck, 'estimatedPalletCount'), false) + const loose = buildTransportDemand('looseboard', { ...valid(), vehicleType: '', productName: ' cargo ', estimatedPalletCount: 3, packageCount: 8 }) + assert.equal(Object.hasOwn(loose, 'vehicleType'), false) + assert.equal(loose.productName, 'cargo') + assert.equal(loose.estimatedPalletCount, 3) + assert.equal(loose.packageCount, 8) +}) diff --git a/src/modules/cross-border/transportDemandForm.ts b/src/modules/cross-border/transportDemandForm.ts new file mode 100644 index 0000000..91d5f31 --- /dev/null +++ b/src/modules/cross-border/transportDemandForm.ts @@ -0,0 +1,106 @@ +import type { FormRules } from 'element-plus' + +export type TransportDemandKind = 'truck' | 'looseboard' +export interface TransportDemandForm { + vehicleType: '' | 'PALLET_15' | 'PALLET_30' + origin: string + destination: string + originAddress: string + destinationAddress: string + originContactName: string + originContactPhone: string + destinationContactName: string + destinationContactPhone: string + productName: string + estimatedPalletCount: number + packageCount: number + totalWeightKg: number + totalVolumeCbm: number + budgetAmount: number | undefined + currency: 'USD' | 'CNY' + pickupAt: string + deliveryAt: string + remark: string +} + +export function createTransportDemandForm(): TransportDemandForm { + return { + vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '', + originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', + productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, + budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '', + } +} + +const requiredText = { + origin: '起运地', destination: '目的地', originContactName: '起运地联系人', + originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话', +} as const + +export function localMinuteInput(value: unknown) { + return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : '' +} + +export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules { + const rules: FormRules = {} + for (const [field, label] of Object.entries(requiredText)) { + rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] + } + if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] + for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { + if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue + rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] + } + rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }] + rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }] + rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, { + validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change', + }] + return rules +} + +export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { + if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' + for (const [field, label] of Object.entries(requiredText)) { + if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}` + } + for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) { + if (!form[field]) return `请选择${label}` + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}` + } + if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间' + for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) { + if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字` + } + if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数' + if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0' + if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种' + return undefined +} + +function commonPayload(form: TransportDemandForm) { + return { + origin: form.origin.trim(), destination: form.destination.trim(), + originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined, + originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), + destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), + packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, + budgetAmount: form.budgetAmount ?? undefined, currency: form.currency, + pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined, + } +} + +type CommonPayload = ReturnType +type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' } +type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number } + +export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload +export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload +export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) { + const failure = transportDemandError(kind, form) + if (failure) throw new Error(failure) + const common = commonPayload(form) + return kind === 'truck' + ? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' } + : { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount } +} diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 50b99f4..e54fbeb 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -9,9 +9,9 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import type { TableAction } from '@/components/common/tableActions' import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue' -import { createProcurement, getProcurement, listProcurements, type ProcurementCreate, type ProcurementDetail, type ProcurementSummary } from '../api/procurement' +import { createProcurement, getProcurement, listProcurements, type ProcurementDetail, type ProcurementSummary } from '../api/procurement' import { buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation' -import { definitelyRejected, procurementRequestKey, restoreProcurementSubmission } from '../procurementSubmission' +import { procurementRequestKey } from '../procurementSubmission' const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false }) const auth = useAuthStore(), route = useRoute(), router = useRouter() @@ -20,11 +20,9 @@ const orderNo = computed(() => typeof route.params.orderNo === 'string' ? route. const canCreate = computed(() => auth.hasPermission('merchant:cross-border:procurement:create')) const canList = computed(() => auth.hasPermission('merchant:cross-border:procurement:list')) const identity = computed(() => auth.token ? accessTokenIdentity(auth.token) : null) -const storageKey = computed(() => identity.value ? `cc-procurement-submit:${encodeURIComponent(identity.value)}` : null) const companyName = computed(() => auth.profile.tenantName || (auth.user?.tenantName && !/^企业租户 \d+$/.test(auth.user.tenantName) ? auth.user.tenantName : '')) const form = reactive(emptyProcurementDraft()) -const pending = shallowRef(null) -const recoveryError = ref(''), submitError = ref(''), submittedOrder = ref('') +const submitError = ref(''), submittedOrder = ref('') const submitting = ref(false), loading = ref(false), error = ref('') const detail = shallowRef(null) const rows = ref([]), total = ref(0) @@ -74,43 +72,22 @@ async function loadDetail() { } function search() { if (query.page === 1) void loadList(); else query.page = 1 } function resetQuery() { const reload = query.page === 1; Object.assign(query, { page: 1, orderNo: '', purchaseCategory: '', productName: '', status: '' }); if (reload) void loadList() } -function populate(payload: ProcurementCreate) { - Object.assign(form, { purchaseCategory: payload.purchaseCategory, productName: payload.productName, productSpecification: payload.productSpecification || '', productUrl: payload.productUrl || '', - purchaseQuantity: String(payload.purchaseQuantity), budgetUnitPrice: String(payload.budgetUnitPrice), currency: payload.currency, receivingAddress: payload.receivingAddress, - contactName: payload.contactName, contactPhone: payload.contactPhone, remark: payload.remark || '' }) -} -function restorePending() { - pending.value = null; recoveryError.value = ''; submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()) - if (!storageKey.value) return - try { const saved = sessionStorage.getItem(storageKey.value); if (saved) { pending.value = Object.freeze(restoreProcurementSubmission(saved)); populate(pending.value) } } - catch { recoveryError.value = '上次提交记录暂无法恢复,请先在订单列表或通过客服核对提交结果,避免重复下单。' } -} +function resetForm() { submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()) } async function submit() { - if (submitting.value || !canCreate.value || recoveryError.value || !storageKey.value) return - submitError.value = ''; const owner = identity.value, key = storageKey.value; const resuming = Boolean(pending.value) - let started = false + if (submitting.value || !canCreate.value || !identity.value || submittedOrder.value) return + const owner = identity.value; submitting.value = true; submitError.value = '' try { - if (!pending.value) { - const payload = buildProcurementCreate(form, companyName.value, procurementRequestKey()) - try { sessionStorage.setItem(key, JSON.stringify(payload)) } catch { throw new Error('浏览器暂无法保留提交信息,请允许会话存储后重试') } - pending.value = Object.freeze(payload) - } - submitting.value = true; started = true - const result = await createProcurement(pending.value) - sessionStorage.removeItem(key) + const result = await createProcurement(buildProcurementCreate(form, companyName.value, procurementRequestKey())) if (!active || owner !== identity.value) return - pending.value = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交') + submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交') if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } }) - } catch (cause) { - if (!active || owner !== identity.value) return - if (!resuming && cause instanceof ApiError && definitelyRejected(cause)) { sessionStorage.removeItem(key); pending.value = null } - submitError.value = started && pending.value ? `${message(cause)}。提交结果尚未确认,请再次确认上次提交结果。` : message(cause) - } finally { submitting.value = false } + } catch (cause) { if (active && owner === identity.value) submitError.value = message(cause) } + finally { submitting.value = false } } watch(() => [mode.value, orderNo.value, query.page, query.size, identity.value], (values, previous) => { if (values[4] !== previous?.[4]) { rows.value = []; total.value = 0 } ++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null - if (mode.value === 'create') restorePending() + if (mode.value === 'create') resetForm() else if (mode.value === 'detail') void loadDetail() else void loadList() }, { immediate: true }) @@ -140,15 +117,13 @@ onBeforeUnmount(() => { active = false; ++requestSequence })

海外采购下单

一单一个商品及规格;预算仅授权样品货款,运费及关税另行核定。

返回列表
- +
diff --git a/src/modules/dashboard/AGENTS.md b/src/modules/dashboard/AGENTS.md new file mode 100644 index 0000000..800ad98 --- /dev/null +++ b/src/modules/dashboard/AGENTS.md @@ -0,0 +1,26 @@ +# 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`。 diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue index 21d97b3..84a0d98 100644 --- a/src/modules/dashboard/views/DashboardView.vue +++ b/src/modules/dashboard/views/DashboardView.vue @@ -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: 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: 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', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' }, + { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer/create', permission: 'merchant:overseas-warehouse:transfer:create', 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: '开始办理' }, ].filter((action) => auth.hasPermission(action.permission))) @@ -23,7 +23,7 @@ const primaryActions = computed(() => [

选择业务,快速下单

-

请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。

+
diff --git a/src/modules/finance/AGENTS.md b/src/modules/finance/AGENTS.md new file mode 100644 index 0000000..b6fa4be --- /dev/null +++ b/src/modules/finance/AGENTS.md @@ -0,0 +1,29 @@ +# 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`。 diff --git a/src/modules/first-leg/AGENTS.md b/src/modules/first-leg/AGENTS.md new file mode 100644 index 0000000..d723bb0 --- /dev/null +++ b/src/modules/first-leg/AGENTS.md @@ -0,0 +1,35 @@ +# 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` 与幂等键,金额与冻结状态只认服务端结果;列表“确认计费重”仅在具备确认权限且服务端 `customerFeeConfirmationRequired=true` 时显示,已确认或未推送费用的订单不显示;详情保留费用查看入口。 +- 报关资料在开始后只读冻结资料,未装柜或无资格时不展示入口;`utils/customsEligibility.ts` 是唯一资格判断入口。 +- 模块间协作只能从本模块 `public.ts` 导入,不穿透其他模块的 `views/` 或内部目录。 + +## 验证 + +- 定向:`pnpm test src/modules/first-leg/<目标测试>`;接口或类型变化同时运行 `pnpm typecheck`。 +- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。 diff --git a/src/modules/first-leg/PROTOTYPE_MATRIX.md b/src/modules/first-leg/PROTOTYPE_MATRIX.md new file mode 100644 index 0000000..8c2fe1d --- /dev/null +++ b/src/modules/first-leg/PROTOTYPE_MATRIX.md @@ -0,0 +1,13 @@ +# 头程下单交货方式调整 + +依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。 + +2026-10-20 补充确认:新建、重置头程订单默认选择“卡车自送”;快速下单生成的草稿同步使用该默认值,编辑已有草稿保留原取货方式。 + +| 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 | +|---|---|---|---|---|---| +| 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 | +| 头程订单 | 编辑草稿 | 现有居中弹框 | 复用新增表单 | 无快递单号输入 | 同步新增规则,回显已保存单号 | +| 头程订单 | 详情 | 现有独立页面 | 保持现有信息区 | 已有快递单号只读展示 | 复用当前展示;说明客户可在快递自提草稿填写单号 | + +自动检查与人工视觉验收分别记录;1920×1080、100% 缩放的页面效果待人工截图确认。 diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index fd7885e..b9e3554 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -139,6 +139,7 @@ export interface FirstLegOrderSaveRequest { privateDestinationAddressId?: number | string cargoes: FirstLegOrderCargoPayload[] pickupMethod: ApiPickupMethod + expressTrackingNo?: string receivingWarehouseId: number | string receivingWarehouseName: string receivingWarehouseAddress?: string | null diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index e7fd13b..4752cc9 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -151,6 +151,7 @@ export interface MerchantTransportFragment { } export interface MerchantUnifiedOrderDetail { + progress: MerchantOrderProgress transportFragments: MerchantTransportFragment[] summary: MerchantUnifiedOrderRow order: MerchantFirstLegOrderDetail diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 0e101dc..270ac4d 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue' +import CustomsHelp from './CustomsHelp.vue' import { MERCHANT_CUSTOMS_STATUS_LABELS, deleteMerchantCustomsDocument, @@ -329,7 +330,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) { - + diff --git a/src/modules/first-leg/components/CustomsHelp.vue b/src/modules/first-leg/components/CustomsHelp.vue new file mode 100644 index 0000000..95ff8f0 --- /dev/null +++ b/src/modules/first-leg/components/CustomsHelp.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index d6ebbff..9a7de06 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -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, /if \(autoOpen\) feeDialogOpen\.value = true/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) - assert.match(source, /平台推送计费重后,可在这里核对并确认/) + assert.match(source, /平台推送费用后,可在这里核对并确认/) assert.match(source, /@closed="emit\('closed'\)"/) }) @@ -30,7 +30,7 @@ test('shows the published merchant inbound, measurement details and customer con } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) assert.match(source, /formatConversionPrecision\(/) - assert.match(source, /确认费用/) + assert.match(source, /title="确认计费重"/) assert.match(source, /确认后当前费用版本将锁定/) assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/) assert.match(source, /confirmMerchantInboundFee/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 1027174..c7f6a57 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -77,7 +77,7 @@ async function confirmFee() { {{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }} - 确认费用 + 确认计费重 查看费用
@@ -198,19 +198,19 @@ async function confirmFee() { - +
- 正在加载计费重 + 正在加载费用
-
{{ props.unavailableMessage }}平台推送计费重后,可在这里核对并确认。
+
{{ props.unavailableMessage }}平台推送费用后,可在这里核对并确认。
diff --git a/src/modules/first-leg/components/TransportFactsTable.vue b/src/modules/first-leg/components/TransportFactsTable.vue index 5c6ae83..178c38c 100644 --- a/src/modules/first-leg/components/TransportFactsTable.vue +++ b/src/modules/first-leg/components/TransportFactsTable.vue @@ -21,7 +21,7 @@ function time(value: string | null) { return value ? value.replace('T', ' ') : ' -

暂无实际装载片段的运输节点。预计时间不表示实际发生。

+

暂无运输节点

diff --git a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs index 9847ce6..9b3288e 100644 --- a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs +++ b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import * as pinia from 'pinia' +import { requireOrderProgress } from '../utils/orderProgress.ts' function compile(path, dependencies) { const source = readFileSync(new URL(path, import.meta.url), 'utf8') @@ -31,10 +32,11 @@ function harness(t) { vue, pinia, '@/modules/first-leg/api/firstLeg': api, '@/modules/first-leg/api/unifiedOrders': { - getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }), + getMerchantUnifiedOrder: async () => ({ progress: { orderId: 'one', stage: 'none', partial: false }, summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }), }, '@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value }, '@/modules/first-leg/utils/warehousePagination': {}, + '@/modules/first-leg/utils/orderProgress': { requireOrderProgress }, }) const store = module.useFirstLegOrdersStore() store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }] @@ -126,41 +128,25 @@ test('cancelled detail summary has no pending overseas synchronization entries', assert.deepEqual(summary, []) }) -test('edit freezes detail version rather than cached list version and replays same key/body after uncertain result', async t => { - const h = harness(t) - const original = await h.store.loadDraftDetail('one') - const session = h.store.beginDraftEdit(original) - h.store.orders = [{ ...original, version: 0 }] - h.behavior.failWrite = true - await assert.rejects(h.store.saveDraft(h.draft, '测试企业', 'one', session), /响应丢失/) - const first = h.calls.find(call => call.method === 'PUT') - assert.equal(first.data.expectedVersion, 9) - assert.equal(first.data.draft.customerRemark, '原草稿') - assert.ok(first.headers['Idempotency-Key']) - assert.deepEqual(h.calls.map(call => call.method), ['GET', 'PUT'], 'no pre-save version refresh') - h.behavior.failWrite = false; h.behavior.version = 10 - const result = await h.store.saveDraft(h.draft, '测试企业', 'one', session) +test('edit preserves the detail version but generates a fresh command after failure', async t => { + const h = harness(t), session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + h.store.orders = [{ id: 'one', version: 0 }]; h.behavior.failWrite = true + await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session), /响应丢失/) + h.behavior.failWrite = false; h.draft.customerRemark = '已修改' + await h.store.saveDraft(h.draft, '客户', 'one', session) const writes = h.calls.filter(call => call.method === 'PUT') - assert.deepEqual(writes[1], first) - assert.deepEqual(h.calls.map(call => call.method), ['GET', 'PUT', 'GET', 'PUT', 'GET']) - assert.equal(result.version, 10) + assert.equal(writes[0].data.expectedVersion, 9); assert.equal(writes[1].data.draft.customerRemark, '已修改') + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) - -test('changed retry payload or missing edit session fails closed without issuing a new write', async t => { - const h = harness(t) - await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one'), /重新打开/) - assert.equal(h.calls.length, 0) - const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) - h.behavior.failWrite = true +test('missing edit session is invalid while changed inputs after failure are accepted', async t => { + const h = harness(t); await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one'), /重新打开/) + const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')); h.behavior.failWrite = true await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session)) - h.draft.customerRemark = '不同内容' - await assert.rejects(h.store.saveDraft(h.draft, '客户', 'one', session), /重试原内容/) - assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) - assert.equal(session.expectedVersion, 9) - assert.ok(Object.isFrozen(session)) + h.draft.customerRemark = '不同内容'; h.behavior.failWrite = false + await h.store.saveDraft(h.draft, '客户', 'one', session) + assert.equal(h.calls.filter(call => call.method === 'PUT').length, 2) }) - -test('read failure after successful edit preserves the same command for retry', async t => { +test('read failure after edit does not retain a frozen request', async t => { const h = harness(t) const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) h.behavior.failRead = true @@ -168,28 +154,17 @@ test('read failure after successful edit preserves the same command for retry', h.behavior.failRead = false await h.store.saveDraft(h.draft, '客户', 'one', session) const writes = h.calls.filter(call => call.method === 'PUT') - assert.deepEqual(writes[0], writes[1]) + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) -test('cancel forwards version and reason without rejecting submitted orders locally; retry keeps key and body', async t => { - const h = harness(t) - h.behavior.status = 'SUBMITTED' - h.store.orders = [{ id: 'one', status: 'PENDING_REVIEW', version: 0 }] - h.behavior.failWrite = true +test('cancel submits the current version and reason after a previous failure', async t => { + const h = harness(t); h.behavior.status = 'SUBMITTED'; h.behavior.failWrite = true await assert.rejects(h.store.cancelOrder('one', 12, '客户要求取消'), /响应丢失/) - const first = h.calls[0] - assert.equal(first.method, 'POST') - assert.equal(first.url, '/merchant/v1/orders/first-leg/one/cancel') - assert.deepEqual(first.data, { expectedVersion: 12, reason: '客户要求取消' }) - await assert.rejects(h.store.cancelOrder('one', 13, '其他原因'), /原版本和原因/) - assert.equal(h.calls.length, 1) - h.behavior.failWrite = false - await h.store.cancelOrder('one', 12, '客户要求取消') - assert.deepEqual(h.calls[2], first) - assert.equal(h.calls[1].method, 'GET') - assert.equal(h.store.orders.length, 0) + h.behavior.failWrite = false; await h.store.cancelOrder('one', 13, '其他原因') + const writes = h.calls.filter(call => call.method === 'POST') + assert.equal(writes.length, 2); assert.deepEqual(writes[1].data, { expectedVersion: 13, reason: '其他原因' }) + assert.notEqual(writes[0].headers['Idempotency-Key'], writes[1].headers['Idempotency-Key']) }) - test('cancel requires a detail version and a nonempty reason', async t => { const h = harness(t) await assert.rejects(h.store.cancelOrder('one', undefined, '理由')) @@ -250,24 +225,13 @@ test('cancel UI uses mandatory-reason centered prompt and refreshes after succes assert.deepEqual(h.calls[1], ['cancel', 'one', 12, '客户申请取消']) }) -test('cancel UI keeps an uncertain command for explicit retry, but conflict requires a fresh decision', async () => { - const h = cancellationViewHarness() - const order = { id: 'one', status: 'DRAFT' } - h.behavior.error = new Error('网络无响应') - await h.handler(order) - assert.equal(h.pendingCancellation.value.expectedVersion, 12) - h.behavior.error = null - await h.handler(order) - assert.equal(h.prompts.length, 1) - assert.equal(h.calls.filter(call => call[0] === 'prepare').length, 1) - assert.equal(h.calls.filter(call => call[0] === 'confirm').length, 1) - assert.deepEqual(h.calls.filter(call => call[0] === 'cancel')[0], h.calls.filter(call => call[0] === 'cancel')[1]) - h.behavior.error = new h.ApiError() - await h.handler(order) - assert.equal(h.pendingCancellation.value, undefined) - assert.match(h.messages.at(-1), /刷新订单后重新确认/) +test('cancel UI releases failure and asks for a current cancellation decision on the next click', async () => { + const h = cancellationViewHarness(), order = { id: 'one', status: 'DRAFT' } + h.behavior.error = new Error('网络无响应'); await h.handler(order) + h.behavior.error = null; await h.handler(order) + assert.equal(h.prompts.length, 2); assert.equal(h.calls.filter(call => call[0] === 'prepare').length, 2) + assert.equal(h.calls.filter(call => call[0] === 'cancel').length, 2) }) - test('cancel UI blocks unavailable permission and irreversible visible stages before reads or writes', async () => { const h = cancellationViewHarness() h.canCancelOrder.value = false @@ -284,23 +248,14 @@ test('both editor entry points capture detail sessions and both save paths pass assert.equal((view.match(/store\.saveDraft(?:ForSubmission)?\(cloneDraft\(form\), tenantStore\.company\.companyName, editingId\.value, editingSession\.value\)/g) ?? []).length, 2) }) -test('submit reads raw detail version rather than list zero and replays original key and version after timeout', async t => { - const h = harness(t) - h.store.orders = [{ id: 'one', status: 'DRAFT', version: 0 }] - h.behavior.failSubmit = true +test('submit uses the current raw version and a fresh key after timeout', async t => { + const h = harness(t); h.store.orders = [{id:'one',status:'DRAFT',version:0}]; h.behavior.failSubmit = true await assert.rejects(h.store.submitSaved('one', 'ACTIVE'), /响应丢失/) - const first = h.calls[1] - assert.equal(first.url, '/merchant/v1/orders/first-leg/one/submit') - assert.deepEqual(first.data, { expectedVersion: 9 }) - assert.ok(first.headers['Idempotency-Key']) - h.behavior.version = 10; h.behavior.status = 'SUBMITTED'; h.behavior.failSubmit = false - await h.store.submitSaved('one', 'ACTIVE') - assert.deepEqual(h.calls[3], first) - assert.deepEqual(h.calls.map(call => call.method), ['GET', 'POST', 'GET', 'POST', 'GET']) - assert.equal(h.store.hasPendingSubmission('one'), false) + h.behavior.version = 10; h.behavior.failSubmit = false; await h.store.submitSaved('one', 'ACTIVE') + const writes = h.calls.filter(call => call.url.endsWith('/submit')) + assert.deepEqual(writes.map(call => call.data.expectedVersion), [9,10]); assert.notEqual(writes[0].headers['Idempotency-Key'],writes[1].headers['Idempotency-Key']) }) - -test('submit refuses missing version or non-draft first decision and preserves command after failed readback', async t => { +test('submit validates current status and releases failure after readback', async t => { const h = harness(t) for (const version of [undefined, -1, 1.5]) { h.behavior.version = version @@ -311,49 +266,25 @@ test('submit refuses missing version or non-draft first decision and preserves c assert.ok(h.calls.every(call => call.method === 'GET')) h.behavior.status = 'DRAFT'; h.behavior.failSubmitRead = true await assert.rejects(h.store.submitSaved('one'), /读取失败/) - assert.equal(h.store.hasPendingSubmission('one'), true) h.behavior.failRead = false; h.behavior.failSubmitRead = false await h.store.submitSaved('one') const posts = h.calls.filter(call => call.method === 'POST') - assert.deepEqual(posts[0], posts[1]) + assert.notEqual(posts[0].headers['Idempotency-Key'], posts[1].headers['Idempotency-Key']) assert.deepEqual(posts[0].data, { expectedVersion: 0 }, 'raw detail zero is valid') }) -test('save then submit retries without another save and changed form requires an explicit new decision', async t => { - const h = harness(t) - const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - h.behavior.failSubmit = true - await assert.rejects(h.store.submitSaved('one')) - const first = h.calls.find(call => call.url.endsWith('/submit')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) - h.draft.customerRemark = '新决定' - await assert.rejects(h.store.saveDraftForSubmission(h.draft, '客户', 'one', session), /重新编辑/) - h.behavior.status = 'SUBMITTED' - await assert.rejects(h.store.beginNewSubmissionEdit('one'), /不能重新编辑/) - assert.equal(h.store.hasPendingSubmission('one'), true) - h.behavior.status = 'DRAFT'; h.behavior.version = 12 - const newSession = await h.store.beginNewSubmissionEdit('one') - assert.equal(newSession.expectedVersion, 12) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', newSession) - h.behavior.version = 13; h.behavior.failSubmit = false - await h.store.submitSaved('one') - const posts = h.calls.filter(call => call.url.endsWith('/submit')) - assert.notEqual(posts[1].headers['Idempotency-Key'], first.headers['Idempotency-Key']) - assert.deepEqual(posts[1].data, { expectedVersion: 13 }) +test('failed submit leaves the draft editable for a fresh save and submission', async t => { + const h = harness(t), session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) + await h.store.saveDraft(h.draft, '客户', 'one', session); h.behavior.failSubmit = true + await assert.rejects(h.store.submitSaved('one')); h.draft.customerRemark = '新内容' + await h.store.saveDraft(h.draft, '客户', 'one', session); h.behavior.failSubmit = false + await h.store.submitSaved('one'); assert.equal(h.calls.filter(call => call.method === 'PUT').length, 2) }) - -test('failed initial submit read does not resave the prepared draft on retry', async t => { - const h = harness(t) - const session = h.store.beginDraftEdit(await h.store.loadDraftDetail('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - h.behavior.failRead = true - await assert.rejects(h.store.submitSaved('one')) - await h.store.saveDraftForSubmission(h.draft, '客户', 'one', session) - assert.equal(h.calls.filter(call => call.method === 'PUT').length, 1) +test('failed initial submit read releases the submission guard for the next click', async t => { + const h = harness(t); h.behavior.failRead = true; await assert.rejects(h.store.submitSaved('one')) + h.behavior.failRead = false; await h.store.submitSaved('one') + assert.equal(h.calls.filter(call => call.url.endsWith('/submit')).length, 1) }) - async function submissionViewHarness(t) { const h = harness(t) const source = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8').match(/ - - - - diff --git a/src/modules/order-management/views/DeliveryOrdersView.vue b/src/modules/order-management/views/DeliveryOrdersView.vue index 37c3ac7..4137b89 100644 --- a/src/modules/order-management/views/DeliveryOrdersView.vue +++ b/src/modules/order-management/views/DeliveryOrdersView.vue @@ -1,16 +1,13 @@ + + + + diff --git a/src/modules/overseas-warehouse/components/RelabelDocumentsPanel.vue b/src/modules/overseas-warehouse/components/RelabelDocumentsPanel.vue new file mode 100644 index 0000000..11b88b2 --- /dev/null +++ b/src/modules/overseas-warehouse/components/RelabelDocumentsPanel.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue new file mode 100644 index 0000000..f3b2e06 --- /dev/null +++ b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/modules/overseas-warehouse/components/ReviewRejectionNotice.test.mjs b/src/modules/overseas-warehouse/components/ReviewRejectionNotice.test.mjs new file mode 100644 index 0000000..4079929 --- /dev/null +++ b/src/modules/overseas-warehouse/components/ReviewRejectionNotice.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { parse } from 'vue/compiler-sfc' + +const source = readFileSync(new URL('./ReviewRejectionNotice.vue', import.meta.url), 'utf8') +const template = parse(source).descriptor.template.content +const render = (status, reason) => renderToString(createSSRApp({ template, props: ['status', 'reason'] }, { status, reason })) + +test('rejected documents display the actual reason safely', async () => { + const html = await render('REJECTED', '箱数不符\n请核对 ') + assert.match(html, /驳回原因/) + assert.match(html, /箱数不符\n请核对 <SKU>/) + assert.match(html, /role="alert"/) +}) + +test('non-rejected documents do not show an old rejection reason', async () => { + for (const status of ['PENDING_REVIEW', 'APPROVED', 'COMPLETED', undefined]) { + assert.doesNotMatch(await render(status, '旧审核原因'), /旧审核原因|驳回原因/) + } +}) + +test('legacy rejection without a reason has an explicit fallback', async () => { + for (const reason of [undefined, '', ' ']) { + assert.match(await render('REJECTED', reason), /暂未记录驳回原因/) + } +}) diff --git a/src/modules/overseas-warehouse/components/ReviewRejectionNotice.vue b/src/modules/overseas-warehouse/components/ReviewRejectionNotice.vue new file mode 100644 index 0000000..52b7652 --- /dev/null +++ b/src/modules/overseas-warehouse/components/ReviewRejectionNotice.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/src/modules/overseas-warehouse/components/TransferPaneDivider.vue b/src/modules/overseas-warehouse/components/TransferPaneDivider.vue new file mode 100644 index 0000000..31233b5 --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransferPaneDivider.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue b/src/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue new file mode 100644 index 0000000..d26d1ce --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransferPickupProgressPanel.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/modules/overseas-warehouse/permissions.test.mjs b/src/modules/overseas-warehouse/permissions.test.mjs index d055d90..67ef313 100644 --- a/src/modules/overseas-warehouse/permissions.test.mjs +++ b/src/modules/overseas-warehouse/permissions.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8') @@ -41,13 +42,31 @@ test('opens warehouse service and return receipt details as dedicated protected }) test('returns to the labeling list after a labeling application is submitted', () => { - assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?await goToServiceList\(\)/) // Return-carton sources use the unified relabel application and its file endpoint. assert.match(services, /returnCartonId: item\.returnCartonId/) - assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?await goToServiceList\(\)/) assert.match(services, /申请已创建,附件待补传/) }) +test('service list return navigates standalone pages and refreshes embedded lists', async () => { + const source = services.slice(services.indexOf('function serviceListRouteName('), services.indexOf('async function openEdit(')) + const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + for (const props of [ + { service: 'labeling', createPage: true, detailPage: false }, + { service: 'labeling', createPage: false, detailPage: true }, + { service: 'labeling', createPage: false, detailPage: false }, + ]) { + const calls = [], mode = { value: 'create' } + const goToServiceList = new Function('props', 'router', 'mode', 'loadPage', `${javascript}; return goToServiceList`)( + props, { push: async route => calls.push(route) }, mode, async () => calls.push('refresh'), + ) + await goToServiceList() + assert.deepEqual(calls, props.createPage || props.detailPage ? [{ name: 'overseas-labeling' }] : ['refresh']) + if (!props.createPage && !props.detailPage) assert.equal(mode.value, 'list') + } +}) + test('keeps overseas warehouse writes behind operation-specific button permissions', () => { for (const permission of [ 'merchant:overseas-warehouse:relabel:create', diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts index f9a225c..392c0de 100644 --- a/src/modules/overseas-warehouse/routes.ts +++ b/src/modules/overseas-warehouse/routes.ts @@ -5,6 +5,17 @@ export const overseasWarehouseModule = { key: 'overseas-warehouse', navigationGroup: { title: '海外仓', icon: Warehouse }, 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', component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active', diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs new file mode 100644 index 0000000..69a3108 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const compiled = ts.transpileModule(readFileSync(new URL('./discardDocuments.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { groupDiscardDocuments } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) + +const returned = (id, warehouse = 'WH', document = 'TT-BYWL-261019152551') => ({ + orderId: `return:${id}`, returnCartonId: id, sourceType: 'RETURN_CARTON', overseasWarehouseId: warehouse, + inboundCode: document, inboundOrderNo: document, availablePackages: 5, cartonSelectionSupported: true, + cartons: [{ cartonId: id, cartonMark: `BOX-${id}`, skus: [{ skuCode: 'A', availablePackages: 5 }] }], skus: [{ skuCode: 'A', availablePackages: 5 }], +}) + +test('同单两箱合并成一个选项,可用量为两箱而非十件,保留原始提交身份', () => { + const sources = [returned('1'), returned('2')] + const result = groupDiscardDocuments(sources) + assert.equal(result.length, 1) + assert.equal(result[0].availablePackages, 2) + assert.deepEqual(result[0].sources, sources) + assert.equal(result[0].cartonSelectionSupported, true) +}) + +test('不同单据或操作仓独立展示,混装SKU不重复计算物理箱', () => { + assert.equal(groupDiscardDocuments([returned('1'), returned('2', 'OTHER'), returned('3', 'WH', 'TT-OTHER')]).length, 3) + const source = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', inventoryPositionId: '1', availablePackages: 10 } + const second = { ...source, orderId: 'inventory:2', inventoryPositionId: '2', cartons: source.cartons } + assert.equal(groupDiscardDocuments([source, second])[0].availablePackages, 1) +}) + +test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细', () => { + const first = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', cartonSelectionSupported: false, availablePackages: 3 } + const second = { ...first, orderId: 'inventory:2', availablePackages: 4 } + const result = groupDiscardDocuments([first, second])[0] + assert.equal(result.availablePackages, 7) + assert.equal(result.cartonSelectionSupported, false) + 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) +}) diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.ts b/src/modules/overseas-warehouse/utils/discardDocuments.ts new file mode 100644 index 0000000..f378944 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/discardDocuments.ts @@ -0,0 +1,61 @@ +import type { DiscardCandidate } from '../api/warehouseServices' + +export interface DiscardDocument extends DiscardCandidate { + sources: DiscardCandidate[] +} + +/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */ +export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] { + const groups = new Map() + const identities = new Map() + 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 key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId]) + let document = groups.get(key) + if (!document) { + document = { ...candidate, orderId: key, sources: [], availablePackages: 0 } + groups.set(key, document) + } + document.sources.push(source) + } + for (const document of groups.values()) { + const boxes = new Set() + let untrackedPackages = 0 + for (const source of document.sources) { + if (source.sourceType === 'RETURN_CARTON' && source.returnCartonId) { + // 退货候选的 availablePackages 是箱内 SKU 件数,一条退货箱只计一箱。 + boxes.add(`RETURN_CARTON:${source.returnCartonId}`) + } else if (source.cartonSelectionSupported && source.cartons?.length) { + for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`) + } else { + // Long 序列化可能返回字符串,必须先转数值再汇总,避免拼成 005010097。 + const packages = Number(source.availablePackages) + untrackedPackages += Number.isFinite(packages) && packages > 0 ? packages : 0 + } + } + document.availablePackages = boxes.size + untrackedPackages + document.cartonSelectionSupported = document.sources.every(source => source.cartonSelectionSupported) + document.cartonSelectionBlockReason = document.sources.find(source => !source.cartonSelectionSupported)?.cartonSelectionBlockReason + } + return [...groups.values()] +} diff --git a/src/modules/overseas-warehouse/utils/relabelSkuDisplay.test.mjs b/src/modules/overseas-warehouse/utils/relabelSkuDisplay.test.mjs new file mode 100644 index 0000000..e4251ce --- /dev/null +++ b/src/modules/overseas-warehouse/utils/relabelSkuDisplay.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./relabelSkuDisplay.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { relabelSkuCodes, relabelSkuCount } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +test('one box with two SKU mappings shows both and counts unique SKUs across boxes', () => { + const item = { skuCode: 'FIRST', skuItems: [{ skuCode: 'FIRST' }, { skuCode: 'SECOND' }] } + assert.deepEqual(relabelSkuCodes(item), ['FIRST', 'SECOND']) + assert.equal(relabelSkuCount([item, { skuCode: 'SECOND' }]), 2) + assert.deepEqual(relabelSkuCodes({ skuCode: 'FIRST,SECOND', skuItems: [] }), ['FIRST', 'SECOND']) +}) diff --git a/src/modules/overseas-warehouse/utils/relabelSkuDisplay.ts b/src/modules/overseas-warehouse/utils/relabelSkuDisplay.ts new file mode 100644 index 0000000..13bd205 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/relabelSkuDisplay.ts @@ -0,0 +1,13 @@ +interface RelabelSkuSource { + skuCode?: string | null + skuItems?: Array<{ skuCode: string }> | null +} + +export function relabelSkuCodes(item: RelabelSkuSource): string[] { + const codes = item.skuItems?.length ? item.skuItems.map(line => line.skuCode) : (item.skuCode || '').split(',') + return [...new Set(codes.map(code => code.trim()).filter(Boolean))] +} + +export function relabelSkuCount(items: RelabelSkuSource[]): number { + return new Set(items.flatMap(relabelSkuCodes)).size +} diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs index 3643b8e..32adc56 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs @@ -8,15 +8,47 @@ const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.Scri const exports = {} new Function('exports', compiled)(exports) +test('多次签收的字符串箱数按数值累计:三箱加四箱等于七箱', () => { + assert.equal(exports.receiptPrintableBoxCount([{ boxCount: '3' }, { boxCount: '4' }]), 7) + assert.equal(exports.receiptPrintableBoxCount([{ boxCount: '3', labels: [{}, {}, {}] }, { boxCount: '4', labels: [{}, {}, {}, {}] }]), 7) +}) + +test('签收箱号只包含箱子和托盘签收,包裹实物记录不重复展示为箱子', () => { + const receipts = [{ receiptNo: 'PARCEL-1', unitType: 'PARCEL' }, { receiptNo: 'BOX-1', unitType: 'BOX' }, { receiptNo: 'PALLET-1', unitType: 'PALLET' }] + const cartons = [{ receiptNo: 'PARCEL-1', cartonMark: 'RP-1' }, { receiptNo: 'BOX-1', cartonMark: 'RC-1' }, { receiptNo: 'PALLET-1', cartonMark: 'RC-2' }, { receiptNo: 'UNKNOWN', cartonMark: 'RP-UNKNOWN' }] + assert.deepEqual(exports.receiptBoxDetails(receipts, cartons).map(item => item.cartonMark), ['RC-1', 'RC-2']) + 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', () => { 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) } }) +test('箱子在签收箱号明细展示一次,真实托盘和缺少箱号明细的实物仍保留', () => { + const boxes = Array.from({ length: 6 }, (_, index) => ({ cartonMark: `RC-${index}` })) + const units = boxes.map(box => ({ unitType: 'BOX', unitCode: box.cartonMark, palletCode: 'PLT-SHARED' })) + assert.deepEqual(exports.receiptPhysicalDetails(units, boxes), []) + const pallet = { unitType: 'PALLET', unitCode: 'PLT-SHARED', palletCode: 'PLT-SHARED' } + const unmatchedBox = { unitType: 'BOX', unitCode: 'RC-UNKNOWN', palletCode: 'PLT-SHARED' } + assert.deepEqual(exports.receiptPhysicalDetails([...units, pallet, unmatchedBox], boxes), [pallet, unmatchedBox]) + assert.deepEqual(exports.receiptPhysicalDetails(units, []), units) +}) + test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => { assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626') assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL' }), '') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL', trackingNo: ' SF123 ' }), 'SF123') assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123') assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '') }) diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts index 57e1087..2f5e1f7 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts @@ -1,10 +1,29 @@ -import type { ReturnReceipt } from '../api/returnOrders' +import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders' + +export function receiptPrintableBoxCount(receipts: ReturnReceipt[]) { + // 只累计真实箱数;缺少箱数时使用已生成标签,不凭空补一箱。 + 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[]) { + const signedBoxMarks = new Set(boxes.map(box => box.cartonMark)) + return pallets.filter(unit => unit.unitType !== 'BOX' || !signedBoxMarks.has(unit.unitCode || unit.palletCode)) +} + +export function receiptBoxDetails(receipts: Pick[], cartons: ReturnReceiptCarton[]) { + const boxReceiptNos = new Set(receipts.filter(receipt => receipt.unitType === 'BOX' || receipt.unitType === 'PALLET').map(receipt => receipt.receiptNo)) + return cartons.filter(carton => boxReceiptNos.has(carton.receiptNo)) +} export function receiptTrackingNo(receipt: Pick | Record) { const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : '' if (trackingNo) return trackingNo const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : '' - return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : '' + return receipt.unitType === 'PARCEL' && code && !/^(?:PK[0-9A-F]{16}|PK-[0-9A-Z]+)$/.test(code) ? code : '' } export function formatReturnVolume(value?: number | null) { diff --git a/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs new file mode 100644 index 0000000..81f6d73 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./returnRelabelCandidates.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText +const { returnRelabelCandidate } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +const converted = { + returnCartonId: '20', cartonMark: 'NEW-BOX', availablePackages: 0, skuItems: [], + cartonAvailablePackages: 1, cartonSkuItems: [{ skuCode: 'NEW-A', quantity: 10 }, { skuCode: 'NEW-B', quantity: 5 }], +} + +test('carton labeling selects the current box and both converted SKUs', () => { + const candidate = returnRelabelCandidate(converted, 'carton') + assert.equal(candidate.cartonMark, 'NEW-BOX') + assert.equal(candidate.availablePackages, 1) + assert.equal(candidate.skuCode, 'NEW-A,NEW-B') + assert.deepEqual(candidate.skuItems, converted.cartonSkuItems) +}) + +test('SKU and combined labeling still exclude completed SKU quantities', () => { + for (const mode of ['sku', 'carton_and_sku']) { + assert.equal(returnRelabelCandidate(converted, mode).availablePackages, 0) + assert.deepEqual(returnRelabelCandidate(converted, mode).skuItems, []) + } +}) + +test('an occupied box cannot be selected for carton labeling', () => { + assert.equal(returnRelabelCandidate({ ...converted, cartonAvailablePackages: 0 }, 'carton').availablePackages, 0) +}) diff --git a/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts new file mode 100644 index 0000000..6dace76 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelCandidates.ts @@ -0,0 +1,11 @@ +import type { RelabelInventoryCandidate } from '../api/warehouseServices' + +export function returnRelabelCandidate(candidate: RelabelInventoryCandidate, mode: string): RelabelInventoryCandidate { + if (!candidate.returnCartonId || mode !== 'carton') return candidate + return { + ...candidate, + availablePackages: candidate.cartonAvailablePackages, + skuItems: candidate.cartonSkuItems, + skuCode: candidate.cartonSkuItems.map(item => item.skuCode).join(','), + } +} diff --git a/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts b/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts new file mode 100644 index 0000000..8289882 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts @@ -0,0 +1,4 @@ +export function returnRelabelStatusLabel(status?: string) { + const labels: Record = { UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中', PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成' } + return status ? labels[status] || '—' : '—' +} diff --git a/src/modules/overseas-warehouse/utils/transferCartonPrinting.test.mjs b/src/modules/overseas-warehouse/utils/transferCartonPrinting.test.mjs new file mode 100644 index 0000000..9508019 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/transferCartonPrinting.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +const source = readFileSync(new URL('./transferCartonPrinting.ts', import.meta.url), 'utf8') +const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText +const exports = {} +new Function('exports', js)(exports) +const prepare = exports.prepareTransferCartonLabels + +test('prints forecast counts independently of quantities or order fields and escapes cargo', () => { + const rows = [{ sku: 'A + + diff --git a/src/modules/overseas-warehouse/views/discardPhotos.test.mjs b/src/modules/overseas-warehouse/views/discardPhotos.test.mjs new file mode 100644 index 0000000..990c99c --- /dev/null +++ b/src/modules/overseas-warehouse/views/discardPhotos.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +function loader(downloadDiscardPhoto) { + const section = source.slice(source.indexOf('let discardPhotoGeneration = 0'), source.indexOf('const fbaWarehouses = ref')) + .replace('loadDiscardPhoto(fileId: string)', 'loadDiscardPhoto(fileId)') + const maps = { discardPhotoUrls: {}, discardPhotoLoading: {}, discardPhotoErrors: {} } + const selectedDetail = { value: { discardNo: 'CD/1', photos: [{ fileId: '101' }] } } + const mode = { value: 'detail' }; const revoked = [] + const URL = { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) } + const functions = new Function('selectedDetail', 'mode', 'downloadDiscardPhoto', 'URL', ...Object.keys(maps), section + ';return { loadDiscardPhotos, loadDiscardPhoto, clearDiscardPhotos }')(selectedDetail, mode, downloadDiscardPhoto, URL, ...Object.values(maps)) + return { ...functions, ...maps, selectedDetail, mode, revoked } +} +test('client detail loads submitted discard photos and frees preview URLs on close', async () => { + const calls = []; const state = loader(async (...args) => { calls.push(args); return {} }) + await state.loadDiscardPhotos() + assert.deepEqual(calls, [['CD/1', '101']]); assert.equal(state.discardPhotoUrls['101'], 'blob:photo') + state.clearDiscardPhotos(); assert.deepEqual(state.revoked, ['blob:photo']); assert.deepEqual(state.discardPhotoUrls, {}) +}) +test('photo failures offer retry and delayed responses cannot populate a closed detail', async () => { + let reject = true + const state = loader(async () => { if (reject) throw new Error('offline'); return {} }) + await state.loadDiscardPhotos(); assert.equal(state.discardPhotoErrors['101'], true) + reject = false; await state.loadDiscardPhoto('101'); assert.equal(state.discardPhotoErrors['101'], false) + let resolve; const slow = loader(() => new Promise(done => { resolve = done })) + const pending = slow.loadDiscardPhotos(); slow.mode.value = 'list'; slow.clearDiscardPhotos(); resolve({}); await pending + assert.deepEqual(slow.discardPhotoUrls, {}); assert.deepEqual(slow.discardPhotoLoading, {}) +}) diff --git a/src/modules/overseas-warehouse/views/inventoryTransferForm.test.mjs b/src/modules/overseas-warehouse/views/inventoryTransferForm.test.mjs new file mode 100644 index 0000000..7505c5a --- /dev/null +++ b/src/modules/overseas-warehouse/views/inventoryTransferForm.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { transpileModule, ScriptTarget } from 'typescript' +import { computed, ref, reactive, watch } from 'vue' + +const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') + +test('退货箱明细与来源下拉使用同一份逐箱库存,不请求普通入仓箱接口', async () => { + const body = view.slice(view.indexOf('async function loadTransferSourceCartons('), view.indexOf('// Snowflake IDs')) + const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText + const ctx = { transferSourceCartons: { value: [] }, transferSourceError: { value: '' }, + transferSourceLoading: { value: false }, inventoryTransfer: { value: true }, + selectedTransferInventory: { value: 'RETURN_ORDER:RET-1' }, form: { warehouse: 'WH' }, + transferInventoryCandidates: { value: [ + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'BOX-1', cartonAvailablePackages: 1, + cartonSkuItems: [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: 2 }] }, + { sourceType: 'RETURN_PARCEL', inboundOrderNo: 'RET-1', cartonMark: 'BOX-2', cartonAvailablePackages: 1, + cartonSkuItems: [{ skuCode: 'A', quantity: 1 }] }, + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonMark: 'FOREIGN', cartonAvailablePackages: 1 }, + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'OCCUPIED', cartonAvailablePackages: 0 }, + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'LABELLED', cartonAvailablePackages: 1, relabelStatus: 'COMPLETED' }, + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'PARTIAL', cartonAvailablePackages: 1, relabelledQuantity: 2 }, + ] }, getTransferSourceCartons: () => { throw Error('不应调用普通来源箱查询') } } + const load = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;' + code + ';return loadTransferSourceCartons')(...Object.values(ctx)) + await load() + assert.deepEqual(ctx.transferSourceCartons.value.map(box => box.cartonMark), ['BOX-1', 'BOX-2']) + assert.equal(ctx.transferSourceCartons.value[0].quantity, 5) + assert.equal(ctx.transferSourceCartons.value[0].items.length, 2) + assert.equal(ctx.transferSourceError.value, '') + assert.equal(ctx.transferSourceLoading.value, false) +}) + +test('选择库存来源时隐藏保存草稿并阻止草稿动作', () => { + assert.match(view, /props\.service === 'transfer' && !inventoryTransfer/) + assert.match(view, /保存草稿/) + assert.match(view, /props\.service === 'transfer' && inventoryTransfer\.value && action === 'draft'/) + assert.match(view, /选择库存来源后不能保存草稿,请直接提交订单/) +}) + +test('来源库存发往FBA默认开启贴标,清除来源或切换私人仓后恢复选项', () => { + const source = ref(''), destination = ref('NONE'), form = reactive({ relabel: false }) + const inventoryTransfer = computed(() => Boolean(source.value)) + const body = view.slice(view.indexOf('const transferRelabelRequired ='), view.indexOf('const transferSourceCartons =')) + const required = new Function('computed', 'watch', 'inventoryTransfer', 'transferDestinationType', 'form', body + ';return transferRelabelRequired')( + computed, watch, inventoryTransfer, destination, form) + destination.value = 'FBA' + assert.equal(form.relabel, false) + source.value = 'IN-1' + assert.equal(required.value, true) + assert.equal(form.relabel, true) + destination.value = 'PRIVATE' + assert.equal(required.value, false) + form.relabel = false + destination.value = 'FBA' + assert.equal(form.relabel, true) + source.value = '' + assert.equal(required.value, false) + assert.match(view, /不需要贴标/) +}) + +function setup() { + const body = view.slice(view.indexOf('async function selectTransferInventory('), view.indexOf('function selectTransferInboundMethod(')) + const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText + const ctx = { selectedTransferInventory: {value:''}, selectedTransferSourceOrderIds:{value:[]}, + transferSourceCartons:{value:[{cartonMark:'OLD'}]}, transferSourceError:{value:'OLD'}, transferSourceLoading:{value:true}, + transferDestinationType:{value:'NONE'}, form:{warehouse:'WH',inboundNo:''}, + transferInventoryCandidates:{value:[{inboundCode:'IN',skuCode:'SKU',productNameZh:'货品',availablePackages:2}, + {inboundCode:'IN',skuCode:'SKU',availablePackages:3},{inboundCode:'OTHER',skuCode:'OTHER',availablePackages:99}]}, + cargoRows:{value:[]}, inventoryQuantity:Number, createCargoRow:()=>({expectedPackageCount:1}), + loadTransferSourceCartons:async()=>{}, listRelabelHistoricalOrders:async()=>[{id:'1234567890123456789'}], + ElMessage:{error(message){throw Error(message)}} } + const select = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;'+code+'; return selectTransferInventory')(...Object.values(ctx)) + return {ctx,select} +} +test('inventory source populates full immutable cargo counts without mixing other inbound orders',async()=>{ + const {ctx,select}=setup(); await select('IN') + assert.equal(ctx.transferDestinationType.value,'FBA') + assert.equal(ctx.cargoRows.value.length,1) + assert.equal(ctx.cargoRows.value[0].expectedPackageCount,5) + assert.equal(ctx.cargoRows.value[0].quantity,5) + assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,['1234567890123456789']) + await select('') + assert.equal(ctx.form.inboundNo,'') + assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,[]) + assert.deepEqual(ctx.transferSourceCartons.value,[]) +}) +test('inventory view hides inbound fields and editing actions and shows original carton destination',()=>{ + assert.match(view,/v-if="props.service === 'transfer' && !inventoryTransfer"[^>]*>\*<\/i>入仓方式/) + assert.match(view,//) + const panel=view.slice(view.indexOf('
',view.indexOf('
{ + assert.match(view, /RETURN_ORDER:\$\{returnNo\}/) + assert.match(view, /退货单|\$\{returnNo\}/) + assert.match(view, /item\.sourceType === 'RETURN_CARTON' \|\| item\.sourceType === 'RETURN_PARCEL'/) + assert.match(view, /isReturnSource[\s\S]*item\.sourceType === 'RETURN_CARTON'/) +}) + +test('退货来源带出实物 SKU 数量并跳过普通历史订单查询', async () => { + const { ctx, select } = setup() + ctx.transferInventoryCandidates.value = [{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', + cartonAvailablePackages: 1, skuItems: [{ skuCode: 'SKU', quantity: 1 }], + cartonSkuItems: [{ skuCode: 'SKU', quantity: 6 }] }, + { sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonAvailablePackages: 1, + cartonSkuItems: [{ skuCode: 'FOREIGN', quantity: 99 }] }] + await select('RETURN_ORDER:RET-1') + assert.equal(ctx.cargoRows.value.length, 1) + assert.equal(ctx.cargoRows.value[0].quantity, 6) + assert.equal(ctx.cargoRows.value[0].expectedPackageCount, 1) + assert.deepEqual(ctx.selectedTransferSourceOrderIds.value, []) +}) diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index b01e5d5..3d7e61a 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -40,9 +40,9 @@ test('贴标申请移除操作项下方的解释文字,保留必要状态', () test('贴标申请可从当前客户仍有库存的入仓单或退货单选择来源', () => { assert.match(view, /海外操作仓(中转仓)/) - assert.match(view, /选择有库存的入仓单或退货单/) + assert.match(view, /选择入仓单或退货单/) assert.match(view, /quantityUnit: candidate\.returnCartonId \? '件' : '箱'/) - assert.match(view, /当前中转仓暂无可用库存/) + assert.match(view, /暂无匹配的可选库存/) assert.match(view, /relabelInboundOptions/) assert.match(view, /listRelabelInventoryCandidates\(form\.warehouse\)/) assert.match(api, /relabel-applications\/inventory-candidates/) @@ -129,20 +129,20 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => { test('箱唛相关换标必须选择四选一派送方式', () => { assert.match(view, /label="卡车派送" value="TRUCK"/) assert.match(view, /label="快递派送" value="EXPRESS"/) - assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/) - assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/) - assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/) + assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/) + assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/) + assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/) assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/) }) test('一次申请单选库存来源且退货库存按退货单汇总', () => { assert.doesNotMatch(view, /multiple filterable collapse-tags/) - assert.match(view, /RETURN_ORDER:\$\{returnOrderNo\}/) + assert.match(view, /returnSourceNos\(item\)\.includes\(selectedReturnOrderNo\.value\)/) assert.match(view, /current\.availablePackages \+= inventoryQuantity\(candidate\.availablePackages\)/) assert.match(view, /availablePackages: inventoryQuantity\(candidate\.availablePackages\)/) assert.match(view, /const quantity = Number\(value\)/) - assert.match(view, /可用散件 SKU/) - assert.match(view, /系统箱唛号/) + assert.match(view, /可用包裹<\/h3>/) + assert.match(view, /仓库箱识别码/) assert.match(view, /箱内 SKU/) assert.match(view, /returnLooseCandidates/) assert.match(view, /returnBoxCandidates/) @@ -174,7 +174,7 @@ test('海外操作仓和FBA目的仓使用独立字段', () => { assert.match(api, /destinationFbaWarehouseCode\?: string/) assert.match(view, /operationWarehouseId: form\.warehouse/) assert.match(view, /destinationFbaWarehouseCode: form\.destination/) - assert.match(view, / { test('审核驳回后展示原因并允许替换资料后重新提交', () => { assert.match(api, /export function resubmitRelabel\(/) - assert.match(view, /贴标单审核已驳回/) + assert.match(view, / { + const start = view.indexOf('async function submit(') + const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start)) + const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo') + const stockRows = [ + { included: false, name: '', sku: 'OLD-SKU', quantity: 1 }, + { included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }, + ] + const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 } + function invalid(details) { + return validate({ service: 'labeling' }, { value: 'carton' }, details, + { value: details }, { value: [...stockRows, ...details] }, { value: [] }) + } + assert.equal(invalid([selected]), false) + assert.equal(invalid([{ ...selected, name: '' }]), true) + assert.equal(invalid([]), true) + assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels') +}) + +const exports = {} +new Function('exports', compiled)(exports) +test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => { + const sources = exports.sourceNos({ inboundOrderNo: ' RETURN-1,RETURN-10, RETURN-1 ' }) + assert.deepEqual(sources, ['RETURN-1', 'RETURN-10']) + assert.equal(sources.includes('RETURN'), false) + assert.deepEqual(exports.sourceNos({}), []) +}) +test('实际完成和处理中占用分别展示,缺失投影保持未知', () => { + assert.equal(exports.progress({ relabelStatus: 'PARTIALLY_COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 2 }), '部分完成|实际完成 3 件|处理中占用 2 件') + assert.equal(exports.progress({}), '—|实际完成 — 件|处理中占用 — 件') + assert.equal(exports.progress({ relabelStatus: 'PROCESSING', relabelledQuantity: 0, relabelReservedQuantity: 4 }), '处理中|实际完成 0 件|处理中占用 4 件') +}) +test('退货 SKU 申请按实物箱选择,来源标识只读,部分数量转换库存', () => { + assert.match(view, /item.id === rows\[0\].sourceRowId/) + assert.match(view, /row.sourceRowId !== source\?\.id/) + assert.match(view, /:readonly="isReturnRelabelSource"/) + assert.match(view, /labelingMode.value === 'sku' \? !skuTotalFile.value : !cartonTotalFile.value/) +}) + +test('整包裹支持SKU和混贴提交,实物数为1且各SKU数量仍按件', () => { + const builder = view.slice(view.indexOf('function buildSkuRelabelItems'), view.indexOf('function addRelabelCargoRow')) + const compiledBuilder = ts.transpileModule(builder, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const cargo = { id: 1, returnCartonId: 'PARCEL-ID', cartonMark: 'RP-1', sku: 'SKU-A', availableQuantity: 1 } + const rows = [{ sourceRowId: 1, cartonMark: 'RP-1', originalSku: 'SKU-A', newSku: 'NEW-A', quantity: 3, fbaCartonMark: 'FBA-NEW', shipmentNo: 'FBA-SHIP-NEW' }, { sourceRowId: 1, cartonMark: 'RP-1', originalSku: 'SKU-B', newSku: 'NEW-B', quantity: 2, fbaCartonMark: 'FBA-NEW', shipmentNo: 'FBA-SHIP-NEW' }] + const candidate = { returnCartonId: 'PARCEL-ID', sourceType: 'RETURN_PARCEL', skuItems: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] } + for (const mode of ['sku', 'carton_and_sku']) { + const build = new Function('skuRelabelRows', 'cargoRows', 'isReturnRelabelSource', 'selectedReturnCandidates', 'labelingMode', 'form', compiledBuilder + '; return buildSkuRelabelItems')({ value: rows }, { value: [cargo] }, { value: true }, { value: [candidate] }, { value: mode }, { destination: 'FBA-WH' }) + const [item] = build() + assert.equal(item.returnCartonId, 'PARCEL-ID') + assert.equal(item.operationType, mode === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU') + assert.equal(item.totalPackageCount, 1) + assert.equal(item.notifiedPackageCount, 1) + assert.deepEqual(item.skuItems.map(sku => sku.quantity), [3, 2]) + if (mode === 'carton_and_sku') { + assert.equal(item.fbaCartonMark, 'FBA-NEW') + assert.equal(item.destinationFbaWarehouseCode, 'FBA-WH') + assert.equal(item.shipmentNo, 'FBA-SHIP-NEW') + rows[1].shipmentNo = 'OTHER-SHIPMENT' + assert.throws(build, /相同的 FBA 货件编号/) + rows[1].shipmentNo = 'FBA-SHIP-NEW' + } else { + assert.equal(item.fbaCartonMark, undefined) + assert.equal(item.destinationFbaWarehouseCode, undefined) + const buildWithoutDestination = new Function('skuRelabelRows', 'cargoRows', 'isReturnRelabelSource', 'selectedReturnCandidates', 'labelingMode', 'form', compiledBuilder + '; return buildSkuRelabelItems')({ value: rows }, { value: [cargo] }, { value: true }, { value: [candidate] }, { value: mode }, { destination: '' }) + assert.equal(buildWithoutDestination()[0].operationType, 'SKU_LABEL') + } + rows[0].quantity = 4 + assert.throws(build, /超过来源箱可用数量/) + rows[0].quantity = 3 + } +}) + +test('包裹箱唛添加保持一个实物并去重,三种类型不再禁用', () => { + const binding = view.slice(view.indexOf('function addReturnBoxBinding'), view.indexOf('')) + const compiledBinding = ts.transpileModule(binding, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const cargoRows = { value: [] } + const bind = new Function('labelingMode', 'cartonDetailRows', 'cargoRows', 'selectedReturnOrderNo', 'addReturnSkuBinding', 'let cargoSequence = 0; ' + compiledBinding + '; return addReturnBoxBinding')({ value: 'carton' }, { value: cargoRows.value }, cargoRows, { value: 'RETURN-1' }, () => assert.fail('箱唛模式不添加SKU行')) + const parcel = { returnCartonId: 'PARCEL-ID', sourceType: 'RETURN_PARCEL', cartonMark: 'RP-1', productNameZh: '测试商品中文名', skuItems: [{ skuCode: 'SKU-A', quantity: 80 }] } + bind(parcel) + bind(parcel) + assert.equal(cargoRows.value.length, 1) + assert.equal(cargoRows.value[0].returnCartonId, 'PARCEL-ID') + assert.equal(cargoRows.value[0].quantity, 1) + assert.equal(cargoRows.value[0].shipmentNo, '') + assert.equal(cargoRows.value[0].name, '测试商品中文名') + assert.doesNotMatch(view, /returnSkuOnly|散件库存只能贴 SKU/) + assert.match(view, /labelingMode === 'sku' \? addReturnSkuBinding\(source, sku.skuCode\) : addReturnBoxBinding\(source\)/) +}) + +test('部分换标明细显示实物来源、旧新SKU、实际件数和待确认状态', () => { + for (const id of ['return-available-skus', 'return-available-boxes']) { + const start = view.indexOf('id="' + id + '"') + const table = view.slice(start, view.indexOf('', start)) + assert.match(table, /SKU 换标结果/) + assert.match(table, /剩余可换(件)/) + assert.match(table, /conversion.oldSkuCode/) + assert.match(table, /conversion.newSkuCode/) + assert.match(table, /conversion.quantity/) + assert.match(table, /item.status !== 'REVERSED'/) + } +}) diff --git a/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs new file mode 100644 index 0000000..8c17e95 --- /dev/null +++ b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const loading = ref(false)')) +function setup(candidates) { + const output = ts.transpileModule(source + '\nexports.options = relabelInboundOptions; exports.visible = visibleRelabelInboundOptions; exports.label = relabelOptionLabel', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + const exports = {}, show = { value: false }, search = { value: '' }, selected = { value: '' } + new Function('exports', 'computed', 'currentRelabelCandidates', 'showOccupiedRelabelInventory', 'relabelInventorySearch', 'selectedRelabelInboundOrderId', 'inventoryQuantity', output)(exports, fn => ({ get value() { return fn() } }), { value: candidates }, show, search, selected, value => Math.max(0, Number(value) || 0)) + return { ...exports, show, search, selected } +} +test('occupied return keeps stock facts but remains hidden when searched', () => { + const context=setup([{ returnCartonId:'C', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:10, inventoryStatus:'RELABEL_PENDING', occupiedRelabelNo:'RL-1' }]) + assert.equal(context.visible.value.length,0) + context.search.value='RT-1'; assert.equal(context.visible.value.length,0) + const label=context.label(context.options.value[0]); assert.match(label,/在仓 1 包裹\/箱 · 10 件/); assert.equal(label,'退货单 RT-1|在仓 1 包裹/箱 · 10 件'); assert.match(context.options.value[0].reasons.join(';'),/换标占用 · RL-1/) + context.search.value=''; assert.equal(context.visible.value.length,0) +}) +test('merged empty source adds history reason without inflating physical stock count', () => { + const context=setup([{ returnCartonId:'OLD', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:0, inventoryStatus:'MERGED', mergedIntoCartonMarks:['NEW'] },{ returnCartonId:'NEW', inboundOrderNo:'RT-1', availablePackages:10, stockQuantity:10, inventoryStatus:'AVAILABLE' }]) + assert.equal(context.options.value[0].units,1); assert.equal(context.options.value[0].stockQuantity,10); assert.equal(context.options.value[0].availablePackages,10) + assert.match(context.options.value[0].reasons.join(';'),/历史部分转入 NEW(不代表当前库存位置)/) +}) +test('missing stock projection stays unknown and outbound history does not count in-warehouse units', () => { + const context=setup([{returnCartonId:'C',inboundOrderNo:'RT-1',availablePackages:0},{returnCartonId:'D',inboundOrderNo:'RT-2',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}]) + assert.match(context.label(context.options.value[0]),/待查询/) + assert.equal(context.options.value[1].units,0);assert.match(context.options.value[1].reasons.join(';'),/已出库/) +}) + +test('fully outbound orders stay hidden even in history search; mixed remaining stock marks partial outbound', () => { + const context=setup([{returnCartonId:'D',inboundOrderNo:'RT-D',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'A',inboundOrderNo:'RT-P',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:2,stockQuantity:2,inventoryStatus:'AVAILABLE'}]) + context.show.value=true + assert.deepEqual(context.visible.value.map(item=>item.inboundOrderNo),['退货单 RT-P']) + context.search.value='RT-D';assert.equal(context.visible.value.some(item=>item.inboundOrderNo==='退货单 RT-D'),false) + assert.equal(context.label(context.options.value[1]),'退货单 RT-P|在仓 1 包裹/箱 · 2 件|部分出库') +}) + +test('partially shipped contents within one physical box show partial outbound', () => { + const context=setup([{returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:3,stockQuantity:3,inventoryStatus:'AVAILABLE',hasOutbound:true}]) + assert.match(context.label(context.options.value[0]),/3 件|部分出库$/) +}) + +test('available box and parcel lists remove zero-quantity sources and zero SKU rows', () => { + const block=view.slice(view.indexOf('const availableReturnCandidates'),view.indexOf('const requiresBothTotalFiles')) + const output=ts.transpileModule(block+'\nexports.boxes=returnBoxCandidates; exports.parcels=returnLooseCandidates',{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText + const exports={} + const sources=[{sourceType:'RETURN_CARTON',cartonMark:'EMPTY',availablePackages:0,skuItems:[{skuCode:'A',quantity:0}]},{sourceType:'RETURN_CARTON',cartonMark:'BOX',availablePackages:3,skuItems:[{skuCode:'A',quantity:0},{skuCode:'B',quantity:3}]},{sourceType:'RETURN_PARCEL',cartonMark:'P0',availablePackages:0},{sourceType:'RETURN_PARCEL',cartonMark:'P1',availablePackages:2,skuItems:[{skuCode:'C',quantity:2}]}] + new Function('exports','computed','selectedReturnCandidates','inventoryQuantity',output)(exports,fn=>({get value(){return fn()}}),{value:sources},value=>Math.max(0,Number(value)||0)) + assert.deepEqual(exports.boxes.value.map(item=>item.cartonMark),['BOX']) + assert.deepEqual(exports.boxes.value[0].skuItems,[{skuCode:'B',quantity:3}]) + assert.deepEqual(exports.parcels.value.map(item=>item.cartonMark),['P1']) + assert.doesNotMatch(view,/显示占用及历史单据|showOccupiedRelabelInventory/) +}) diff --git a/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs index 678ee90..41b0c89 100644 --- a/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs @@ -5,8 +5,55 @@ import test from 'node:test' const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => { - assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s) - assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s) - assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s) + assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*@change="selectDiscardWarehouse"/s) + assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*@change="selectDiscardInbound"/s) + assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s) assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/) }) + + +test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => { + const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1] + const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardDocuments', 'cargoRows', 'form', 'cargoSequence', 'orderId', body) + const cargoRows = { value: [] }; const form = {} + const source = { orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] } + select({ value: [] }, { value: '' }, () => {}, { value: [{ ...source, orderId: 'document:WT-1', sources: [source] }] }, cargoRows, form, 0, 'document:WT-1') + assert.equal(cargoRows.value[0].quantity, 100) + assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42') + assert.equal(form.inboundNo, 'WT-1') + assert.match(view, /orderId: item.sourceType === 'DROPSHIP' \? item.sourceOrderId : undefined/) + assert.match(view, /inventoryPositionId: item.sourceType === 'INVENTORY'/) +}) + +function selectionRows(boxes, selectedKeys) { + const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map()', 'new Map()') + const rows = { value: [] } + new Function('discardSourceBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({})) + return rows.value +} +test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => { + const source = { orderId: 'inventory:42', inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 3, skus: [{ skuCode: 'SKU', productName: '商品' }] } + const boxes = ['1', '2', '3'].map(id => ({ key: `INVENTORY:${id}`, cartonId: id, cartonMark: `BOX-${id}`, source, skus: source.skus })) + const rows = selectionRows(boxes, ['INVENTORY:2']) + assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1) + assert.deepEqual(rows[0].discardCartonIds, ['2']); assert.equal(rows[0].sourceInventoryPositionId, '42') + assert.equal(selectionRows(boxes, []).length, 0) +}) +test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU', () => { + const skus = [{ skuCode: 'A', availablePackages: 2 }, { skuCode: 'B', availablePackages: 3 }] + const dropship = { orderId: '42', sourceType: 'DROPSHIP', availablePackages: 1, skus } + const rows = selectionRows([{ key: 'DROPSHIP:1', cartonId: '1', source: dropship, skus }], ['DROPSHIP:1']) + assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1); assert.deepEqual(rows[0].discardCartonIds, ['1']) + const returned = { orderId: 'return:2', returnCartonId: '2', sourceType: 'RETURN_CARTON', availablePackages: 5, skus } + 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)) +}) + +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']]]) +}) diff --git a/src/modules/overseas-warehouse/views/warehouseServiceLifecycle.test.mjs b/src/modules/overseas-warehouse/views/warehouseServiceLifecycle.test.mjs new file mode 100644 index 0000000..dc364fe --- /dev/null +++ b/src/modules/overseas-warehouse/views/warehouseServiceLifecycle.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue' +import ts from 'typescript' + +const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const detailNumber = source.match(/^const detailNumber = .*$/m)[0] +const routeWatcher = source.slice(source.indexOf('watch(() => [props.service,'), source.indexOf('watch(mode,')) +const activation = source.slice(source.indexOf('let activatedOnce = false'), source.indexOf('onBeforeUnmount(() =>')) +const code = ts.transpileModule([detailNumber, routeWatcher, activation].join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText +function harness(detailPage = false) { + const scope = effectScope(), props = reactive({ service: 'transfer', createPage: false, detailPage, detailNo: '' }) + const route = reactive({ params: { detailNo: detailPage ? 'TRANSFER-A' : undefined } }) + const mode = ref(detailPage ? 'detail' : 'list'), page = ref(3), filters = reactive({ number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' }) + const calls = [], activationCallbacks = [] + const resetFilters = () => { calls.push('resetFilters'); Object.assign(filters, { number: '', warehouse: '', status: '' }) } + const loadPage = () => { calls.push({ kind: 'list', page: page.value, ...filters }) } + scope.run(() => new Function('computed', 'watch', 'props', 'route', 'mode', 'page', 'resetFilters', 'resetForm', 'openCreate', 'loadDetailPage', 'loadPage', 'onActivated', code)(computed, watch, props, route, mode, page, resetFilters, () => calls.push('resetForm'), () => calls.push('create'), () => calls.push('detail'), loadPage, callback => activationCallbacks.push(callback))) + return { scope, props, route, mode, page, filters, calls, activate: () => activationCallbacks.forEach(callback => callback()) } +} + +test('cached transfer list ignores detail navigation and refreshes once on return with its original filters and page', async () => { + const state = harness() + try { + state.activate() + state.route.params.detailNo = 'TRANSFER-A' + await nextTick() + assert.deepEqual(state.calls, []) + assert.equal(state.page.value, 3) + assert.equal(state.filters.number, 'TRANSFER-FILTER') + state.route.params.detailNo = 'TRANSFER-B' + await nextTick() + assert.deepEqual(state.calls, []) + state.route.params.detailNo = undefined + await nextTick() + state.activate() + assert.deepEqual(state.calls, [{ kind: 'list', page: 3, number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' }]) + } finally { state.scope.stop() } +}) + +test('real detail pages still react to another detail number without loading a background list', async () => { + const state = harness(true) + try { + state.route.params.detailNo = 'TRANSFER-B' + await nextTick() + assert.deepEqual(state.calls, ['resetFilters', 'resetForm', 'detail']) + } finally { state.scope.stop() } +}) diff --git a/src/modules/overseas-warehouse/views/warehouseTransferCartonDisplay.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferCartonDisplay.test.mjs new file mode 100644 index 0000000..3a760e5 --- /dev/null +++ b/src/modules/overseas-warehouse/views/warehouseTransferCartonDisplay.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const functions = source.slice(source.indexOf('function transferCartonItem('), source.indexOf("const detailError = ref('')")) +const code = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText +const load = selectedDetail => new Function('selectedDetail', `${code}; return { transferCartonLines, palletSkuRows, palletQuantity, receivedItemCartons, receivedItemQuantity };`)(selectedDetail) + +test('mixed carton detail and pallet summaries preserve every SKU without duplicating boxes', () => { + const mixed = { status: 'COUNTED', transferItemId: '1', transferPalletId: '9', quantity: 10, items: [{ transferItemId: '1', skuCode: 'A', quantity: 3 }, { transferItemId: '2', skuCode: 'B', quantity: 7 }] } + const display = load({ value: { items: [{ id: '1', skuCode: 'A', productNameZh: '产品A' }, { id: '2', skuCode: 'B', productNameZh: '产品B' }], cartons: [mixed, { ...mixed, status: 'NOT_RECEIVED', quantity: 999 }] } }) + assert.equal(display.transferCartonLines(mixed).length, 2) + assert.equal(display.transferCartonLines(mixed)[1].productNameZh, '产品B') + assert.deepEqual(display.palletSkuRows({ id: '9' }), [{ sku: 'A', quantity: 3 }, { sku: 'B', quantity: 7 }]) + assert.equal(display.palletQuantity({ id: '9' }), 10) + assert.equal(display.receivedItemQuantity('1'), 3) + assert.equal(display.receivedItemQuantity('2'), 7) + 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) +}) diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index b66bbd7..18bb89b 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -30,7 +30,6 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否 assert.match(view, />是否贴标不需要贴标/) assert.match(view, /:value="true">需要贴标/) - assert.match(view, /FBA 仓和私人仓均可选择是否贴标/) assert.doesNotMatch(view, /v-model="form\.relabel" disabled/) assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/) assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/) @@ -44,7 +43,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否 test('换标详情待客户确认时提供确认入口并刷新详情', () => { assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/) - assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/) + assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/) + assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/) }) test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => { @@ -55,7 +55,7 @@ test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => { test('仓储转运换标整票只允许一个目的仓', () => { assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/) - assert.match(view, /FBA 货件编号<\/span>SKU 关联<\/strong>/) @@ -101,15 +101,12 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换 assert.doesNotMatch(view, /FNSKU \/ 箱唛<\/th>/) }) -test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => { - assert.match(view, /printTransferDraftCartonMarks/) - assert.match(view, /请先填写商品 SKU 和对应的整数箱数/) - assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/) - assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/) - assert.match(view, /仓储转运客户箱唛/) - assert.match(view, /@click="printTransferDraftCartonMarks"/) - assert.match(view, /打印唛头/) - assert.match(view, /单次最多打印 500 张箱唛/) +test('仓储转运填写货物即可打印,保存或提交保留已打印箱号', () => { + assert.doesNotMatch(view, /printTransferDraftCartonMarks|CT-\$\{batch\}/) + assert.match(view, /getTransferCartonLabel\(transferNo\)/) + assert.match(view, /打印预报箱唛/) + assert.match(view, /@click="printForecastCartons"/) + assert.match(view, /cartonMarks: item.generatedCartonMarks/) }) test('仓储转运换标资料按步骤分组展示', () => { @@ -145,7 +142,7 @@ test('仓储转运换标总文件与明细模板在桌面端左右排列', () => }) test('上门提货不提交物流号,需要换标时随转运单登记申请', () => { - assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/) + assert.match(view, /trackingNo:\s*!inventoryTransfer\.value && form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/) assert.match(view, /relabelItems: form\.relabel/) assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s) assert.match(view, /提货联系人/) @@ -155,9 +152,8 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请', assert.match(view, /form\.pickupPhone/) assert.match(view, /form\.relabelContact/) assert.match(view, /form\.relabelPhone/) - assert.match(view, /选择海外仓后自动带出,也可手动填写/) - assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/) - assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/) + assert.match(view, /pickupContactName: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/) + assert.match(view, /pickupContactPhone: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/) assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/) assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/) }) @@ -174,7 +170,7 @@ test('仓储转运期望发货时间可选并在订单概览回填发货时间', assert.doesNotMatch(view, /预计发货时间/) assert.match(view, /期望发货时间/) assert.match(view, /v-model="form\.estimatedShipAt"/) - assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/) + assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder=""/) assert.doesNotMatch(view, /!form\.estimatedShipAt/) assert.match(view, /estimatedShipAt: form\.estimatedShipAt/) assert.match(view, /期望发货时间<\/span>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/) @@ -210,7 +206,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着 test('仓储转运详情订单概览使用详情接口命名字段回填', () => { assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/) assert.doesNotMatch(view, /入库箱数/) - assert.match(view, /'转运单号', '箱数', '海外仓'/) + assert.match(view, /'转运单号', '预计箱数', '海外仓'/) for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) { assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`)) } @@ -258,7 +254,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', () test('仓储转运详情按接口具名字段展示订单概览', () => { const overview = view.split('

订单概览

')[1]?.split('warehouse-transfer-cargo-card')[0] || '' assert.match(overview, /v-if="props\.service === 'transfer'"/) - assert.match(overview, /selectedDetail\?\.transferNo/) + assert.doesNotMatch(overview, /转运单号<\/span>/) + assert.match(view.split('
')[1]?.split('
')[0] || '', /selectedDetail\?\.transferNo/) assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/) assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/) assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/) @@ -272,26 +269,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据' assert.match(view, /confirmTransferFromList\(item\['0'\]\)/) assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/) assert.match(view, /PDA 打托实测数据/) - for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true) + for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true) assert.match(view, /pallet\.warehouseLocationCode/) - assert.match(view, /pallet\.cartonCount/) + assert.match(view, /palletCartons\(pallet\)\.length/) assert.match(view, /pallet\.grossWeightKg/) assert.match(view, /pallet\.volumeCbm/) assert.doesNotMatch(view, /pallet\.items/) }) -test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => { +test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => { const detailStart = view.indexOf('
') const detailSource = view.slice(detailStart) const overviewIndex = detailSource.indexOf('

订单概览

') const measurementIndex = detailSource.indexOf('

PDA 打托实测数据

') - const cargoIndex = detailSource.indexOf('

货物明细

') + const cargoIndex = detailSource.indexOf('

预报与实际签收

') assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex) - assert.ok(measurementIndex < cargoIndex) + assert.ok(cargoIndex < measurementIndex) assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/) assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/) assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/) - assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/) + assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/) }) test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => { @@ -305,7 +302,7 @@ test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => assert.match(view, /加载失败,点击重试/) assert.match(view, /URL\.createObjectURL\(blob\)/) assert.match(view, /URL\.revokeObjectURL\(url\)/) - assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\) \}\)/) + assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\); clearDiscardPhotos\(\) \}\)/) }) test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => { @@ -328,7 +325,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提 assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/) assert.match(view, /await submitTransfer\(transferNo\)/) assert.match(view, /
]*@submit\.prevent="submit\('submit'\)"/) - assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/) + assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/) assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s) assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/) assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/) @@ -339,7 +336,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提 test('编辑仓储转运时加载库存转运来源候选', () => { assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/) assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/) - assert.match(view, /transferInventoryCandidates\.value = await listRelabelInventoryCandidates\(warehouseId\)/) + assert.match(view, /transferInventoryCandidates\.value = \(await listRelabelInventoryCandidates\(warehouseId\)\)\.filter/) assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/) assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/) assert.match(view, /detail\.sourceOrders \|\| \[\]/) @@ -361,9 +358,51 @@ test('转运贴标单在PDA确认库存前不误报附件上传失败', () => { test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => { assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/) assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/) - assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/) + assert.match(view, /props\.service === 'relabel' \? '请输入商品 SKU' : ''/) assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+旧 SKU<\/th>/) assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/) assert.match(api, /skuCode\?: string; productNameZh\?: string/) assert.match(api, /originalSku\?: string/) }) + + test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => { + assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/) + assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/) + assert.match(view, /v-model="row.expectedPackageCount"/) + assert.match(view, /Number.isSafeInteger\(row.quantity\)/) + assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/) + assert.match(view, /\['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'\]\.includes\(carton.status\)/) + assert.match(view, /实际签收箱数/) +}) + +test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => { + 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, /String\(carton.transferPalletId\) === String\(pallet.id\)/) + assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/) + assert.match(view, /@click="showTransferCartons = true"/) + assert.match(view, /')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"')) + assert.doesNotMatch(receiptSection.split('\{\{ \[pallet.lengthCm/) +}) + + +test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => { + const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('
')) + const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start)) + assert.ok(section.includes('预报与实际签收')) + assert.ok(section.includes('PDA 打托实测数据')) + assert.ok(section.includes('receivedItemQuantity(item.id)')) + assert.ok(section.includes('receivedItemCartons(item.id).length')) + assert.ok(section.includes('class="warehouse-transfer-pallet-card"')) + assert.ok(section.includes('class="warehouse-transfer-pallet-sku"')) + assert.doesNotMatch(section, /打印箱唛/) + const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('`。 +- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。 diff --git a/src/modules/tenant-management/AGENTS.md b/src/modules/tenant-management/AGENTS.md new file mode 100644 index 0000000..eeb72ac --- /dev/null +++ b/src/modules/tenant-management/AGENTS.md @@ -0,0 +1,30 @@ +# 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`。 diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 55855e0..0ef5e53 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -57,7 +57,6 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ { code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' }, { code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' }, { code: 'merchant:warehouse:delivery:list', label: '查看派送单', description: '分页查询本企业海外仓派送单' }, - { code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' }, ], }, { @@ -75,6 +74,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ { code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' }, { code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' }, { code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' }, + { code: 'merchant:overseas-warehouse:discard:confirm', label: '确认废弃完成', description: '核对本企业废弃现场照片并确认完结' }, { code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' }, { code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' }, { code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' }, @@ -144,7 +144,6 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly { { title: '首页', children: undefined }, { title: '订单中心', - children: ['头程订单', '入库订单', '派送订单', '海外采购', '美国本土卡', '美本土散板'], + children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'], }, { title: '报关资料', children: undefined }, { title: '船期价格', children: undefined }, @@ -88,8 +88,6 @@ test('flattens visible leaves with unique paths', () => { assert.deepEqual(paths, [ '/', '/orders/first-leg', - '/orders/inbound', - '/orders/delivery', '/cross-border/procurement', '/cross-border/domestic-trucking', '/cross-border/pallet-shipping', @@ -121,22 +119,10 @@ test('filters inaccessible leaves and empty navigation groups', () => { assert.equal(getFirstAccessiblePath([]), '/forbidden') }) -test('exposes active inbound and delivery queries only with their existing read permissions', () => { - const deliveryOnly = ['merchant:warehouse:delivery:list'] - const inboundOnly = ['merchant:warehouse:inbound:list'] - 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('omits inbound and delivery entries from the customer sidebar', () => { + const permissions = ['merchant:warehouse:delivery:list', 'merchant:warehouse:inbound:list'] + assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions)), []) + assert.equal(getFirstAccessiblePath(permissions), '/forbidden') }) test('excludes entries marked for manual hiding without affecting other visible items', () => { @@ -383,6 +369,8 @@ test('exposes customer business lists and keeps utility routes outside navigatio ] const hiddenActivePaths = new Set([ + 'orders/inbound', + 'orders/delivery', 'first-leg/quick-order', 'overseas-warehouse/return-inventory', 'overseas-warehouse/return-cartons', diff --git a/src/navigation/clientNavigation.ts b/src/navigation/clientNavigation.ts index 321af20..be4b6c3 100644 --- a/src/navigation/clientNavigation.ts +++ b/src/navigation/clientNavigation.ts @@ -52,8 +52,6 @@ export const clientNavigation: ClientNavigationNode[] = [ icon: ClipboardList, children: [ requireLeaf('first-leg-orders', '头程订单'), - requireLeaf('orders-inbound', '入库订单'), - requireLeaf('orders-delivery', '派送订单'), requireLeaf('cross-border-procurement'), requireLeaf('cross-border-domestic-trucking'), requireLeaf('cross-border-pallet-shipping'),