Compare commits

...
Author SHA1 Message Date
ouyangxq 24ebfcf2e1 fix(booking): retain saved cargo values in numeric inputs 2026-10-22 15:17:45 +08:00
ouyangxq aac878c3d4 fix(dispatch): finish original truck appointment linking 2026-10-22 14:55:53 +08:00
ouyangxq d49210575b fix(booking): sync cargo input accessibility with edit state 2026-10-22 14:54:48 +08:00
ouyangxq 30a0d7ce68 优化 2026-10-22 14:38:11 +08:00
ouyangxq 29f4791b66 fix(inbound): refresh authoritative container arrival when opening plan 2026-10-22 14:08:06 +08:00
ouyangxq 3e77c3976e 优化 2026-10-22 11:56:54 +08:00
ouyangxq 090431b409 fix(dispatch): release failed request state and return rejected reviews 2026-10-22 10:10:32 +08:00
ouyangxq ef85ed30e4 合并远端尾程列表页面更新 2026-10-21 23:24:33 +08:00
ouyangxq 2e26477db5 修复订单货柜预约与排车维护界面 2026-10-21 23:20:10 +08:00
liuyihe 6d2ef9fffc Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 21:50:16 +08:00
liuyihe ad33b21869 优化 2026-10-21 21:50:14 +08:00
ouyangxq 93a31b3872 优化 2026-10-21 21:30:31 +08:00
ouyangxq d8fbc16fd7 fix(dispatch): unify loading document tabs and remove duplicate photo prompt 2026-10-21 21:11:44 +08:00
ouyangxq dbd7c473c2 fix(dispatch): 展示审核预约时间并简化装车确认 2026-10-21 19:36:58 +08:00
ouyangxq 875d2b2b04 fix(admin): 补齐预约排车和业务夹具的发布字段 2026-10-21 18:59:30 +08:00
ouyangxq e959a99db8 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-21 18:51:30 +08:00
ouyangxq a4cb6c3757 feat(admin): 排车默认发布散板需求并直接确认装车 2026-10-21 18:51:10 +08:00
liuyihe c367327049 优化 2026-10-21 18:32:18 +08:00
ouyangxq 7c43fcacc4 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-21 18:15:51 +08:00
ouyangxq 2ecf4cf3fe fix(admin): 隐藏散板需求通往旧车次页面的入口 2026-10-21 18:15:14 +08:00
liuyihe bc27480e2c Merge origin/release/test and preserve pickup review photos 2026-10-21 18:13:56 +08:00
liuyihe 872d197531 优化 2026-10-21 18:11:48 +08:00
ouyangxq b835c1cdfc fix(admin): 补齐运输资源候选显隐事件类型 2026-10-21 18:08:06 +08:00
ouyangxq ba072611dc 优化 2026-10-21 18:05:02 +08:00
ouyangxq 98049c4f8c 优化 2026-10-21 17:57:02 +08:00
ouyangxq 64667af5c6 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 17:56:57 +08:00
ouyangxq 4f5cec1b94 优化 2026-10-21 17:49:04 +08:00
liuyihe c367fadf09 Merge release/test and preserve pickup execution and photo workflows 2026-10-21 17:35:43 +08:00
liuyihe fde16235ea 优化 2026-10-21 17:28:04 +08:00
ouyangxq 22e60248d2 优化 2026-10-21 17:26:49 +08:00
ouyangxq f8c9334e98 优化 2026-10-21 17:22:25 +08:00
ouyangxq 67acd02ea5 优化 2026-10-21 17:08:59 +08:00
ouyangxq c510cc4e2e 优化 2026-10-21 16:58:38 +08:00
ouyangxq e2f99b4f4b 优化 2026-10-21 16:40:10 +08:00
liuyihe 5e8daad5a0 Merge origin/release/test and preserve pickup workflow 2026-10-21 16:22:08 +08:00
liuyihe 215cb7c6d5 优化 2026-10-21 16:12:24 +08:00
ouyangxq 8356e98ce3 优化 2026-10-21 15:53:21 +08:00
ouyangxq 6ab3bbe289 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 15:53:19 +08:00
ouyangxq a4c7f8edd5 优化 2026-10-21 15:49:35 +08:00
liuyihe a9c0285e22 优化 2026-10-21 15:20:24 +08:00
ouyangxq e6c864a2a6 优化 2026-10-21 15:10:54 +08:00
ouyangxq 457a95941d style(fleet): unify owned business row action buttons 2026-10-21 15:05:56 +08:00
ouyangxq fc4c14e420 fix(fleet): accept vehicle ownership for pool-based business lists 2026-10-21 14:57:17 +08:00
ouyangxq 8498798a9c 优化 2026-10-21 14:30:33 +08:00
ouyangxq 3953453090 fix(dispatch): type existing cargo selection event 2026-10-21 13:36:57 +08:00
ouyangxq 2333bd8bab 优化 2026-10-21 13:22:42 +08:00
ouyangxq eb0b0f85d1 fix(calendar): search dispatches by container and clarify local dates 2026-10-21 11:38:19 +08:00
ouyangxq db3b5f7157 优化 2026-10-21 07:53:28 +08:00
ouyangxq 876930b07e test(admin): 同步弹窗样式与预览挂载回归断言 2026-10-21 07:45:36 +08:00
ouyangxq 0a1968a134 fix(admin): 完善弹窗滚动与表单交互布局 2026-10-21 07:40:08 +08:00
ouyangxq 2f9a3330e5 feat(admin): 实现排车候选分页并修正报关批次参数 2026-10-21 07:19:57 +08:00
ouyangxq 1c771066ea 1 2026-10-20 23:49:50 +08:00
ouyangxq e60f1b300a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-20 23:49:47 +08:00
ouyangxq 13e4aa56ec 优化 2026-10-20 23:34:11 +08:00
liuyihe 8afd04aa55 优化 2026-10-20 21:33:46 +08:00
liuyihe 00b1fefecb 优化 2026-10-20 18:19:19 +08:00
liuyihe 88632e6e4f 优化 2026-10-20 17:59:13 +08:00
liuyihe 01ecd63991 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-20 17:25:54 +08:00
liuyihe 3598310237 优化 2026-10-20 17:24:50 +08:00
liuyihe 6b3286fa5a 优化 2026-10-20 16:58:25 +08:00
ouyangxq f155976f52 优化 2026-10-20 16:45:09 +08:00
ouyangxq db32f31cc2 优化 2026-10-20 15:48:27 +08:00
ouyangxq 489b1bcd16 优化 2026-10-20 15:48:23 +08:00
ouyangxq 291535e242 style(admin): 放大订单、费用、测量与库位弹框宽度
- 订单:审核/扣货/合柜报关/同柜组成员 720→880,编辑备注 880→1000,导入旧系统订单 760→880
- 入库费用:新增/编辑费用 880→1000,驳回费用 720→880
- 测量:手动录入箱规 880→1000,确认称重图片 920→1080
- 库位:新增/编辑库位 880→1000
- 同步 InboundFeeDetailManager、LocationsView 的宽度断言
2026-10-20 15:26:18 +08:00
liuyihe 1e80470078 Merge origin/release/test and resolve frontend conflicts 2026-10-20 14:58:58 +08:00
liuyihe 6f7f8e7632 优化 2026-10-20 14:52:06 +08:00
ouyangxq 03b1e7163e 优化 2026-10-20 14:25:01 +08:00
liuyihe 5679b156ca 优化 2026-10-20 14:00:49 +08:00
ouyangxq 3879a47673 docs(admin): align fee-item and measurement guides with current contracts
按现行 OpenAPI 与计费规则修正两处业务偏差,并统一重复规则的维护位置。

- 费用项目:feeCode 允许编辑(OpenAPI 仅约束格式与长度),删除“编码不可变”表述;defaultPrice 改为“带入初值、允许人工修改”,明确为空与 0 的语义及不追改已有费用
- 入库测量:区分平台链路(PlatformInboundMeasurementController -> InboundMeasurementService/MeasurementSubmissionService)与设备上报(MojiaMeasurementOpenApiService)
- 箱规算法与 actualMeasurementRows 口径不再在模块指引重复正文,改为指向详细索引
2026-10-20 13:41:28 +08:00
ouyangxq b34626bd4b docs(admin): add module-level AGENTS indexes for all 33 modules
新增 29 个模块级 AGENTS.md,补齐入口表指向,使 33 个业务模块均可从入口直达页面与接口。

- 每个模块指引统一为:路径基准 / 页面与入口表 / 关键边界 / 验证入口
- 入口表 35 行改为指向 modules 下的 AGENTS.md,补齐费用项目、审计日志、尺寸配置、装柜照片等缺失词条
- 详细索引最长行从 1315 字符压缩到 699 字符,规则正文移入新增的“货柜工作台规则”小节
- 仅索引文档,未改动业务代码
2026-10-20 12:45:00 +08:00
liuyihe 7005228b52 优化 2026-10-20 11:54:36 +08:00
liuyihe 836e210b09 Merge origin/release/test and preserve local frontend changes 2026-10-20 11:48:38 +08:00
liuyihe 41a498657c 优化 2026-10-20 11:33:39 +08:00
ouyangxq f4224e5d3a fix(admin): present dispatch vehicle statuses and scoped notices 2026-10-20 10:17:17 +08:00
ouyangxq 3267679758 style(admin): unify dialog layouts and dense readonly presentation 2026-10-20 10:11:31 +08:00
ouyangxq 7ff7c9914f Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-19 17:56:41 +08:00
ouyangxq 5c28cd7c79 优化 2026-10-19 17:56:31 +08:00
ouyangxq 5a46198bf0 feat(appointments): add FBA creation and destination filtering 2026-10-19 17:02:27 +08:00
607 changed files with 32561 additions and 8773 deletions
+52 -58
View File
@@ -2,13 +2,13 @@
## 快速读取顺序
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
## 项目定位与技术基线
@@ -36,61 +36,54 @@
## 模块快速路由
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
| 业务 | 首个入口 |
|---|---|
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
| 消息中心 | `modules/notifications/AGENTS.md` |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
## 海外模块内部快速索引(海外仓全流程)
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
## 业务、安全与共享装配
@@ -105,7 +98,7 @@
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
## 开发验收身份
@@ -127,7 +120,8 @@ pnpm typecheck
pnpm build
```
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
+70
View File
@@ -0,0 +1,70 @@
# Admin业务详细索引
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
## 模块快速路由
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
## 货柜工作台规则
- 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/`
## 海外模块内部快速索引(海外仓全流程)
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
+3 -2
View File
@@ -5,8 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
"preview": "vite preview --port 4173"
+71 -2
View File
@@ -1,7 +1,10 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { defineComponent, markRaw } from 'vue'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { ElMenu, ElMenuItem, ElSubMenu } from 'element-plus'
import { describe, expect, it, vi } from 'vitest'
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
@@ -85,6 +88,72 @@ describe('AdminSidebarMenu', () => {
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
})
it('fits collapsed icons inside the scrolling rail and keeps hover submenus outside it', async () => {
const host = document.createElement('div')
host.className = 'app-shell is-collapsed'
host.innerHTML = '<aside class="app-sidebar"><nav class="side-nav"></nav></aside>'
document.body.append(host)
const style = document.createElement('style')
style.textContent = readFileSync(resolve(process.cwd(), 'src/styles/shell.css'), 'utf8')
document.head.append(style)
const rail = host.querySelector<HTMLElement>('.side-nav')!
const router = routerPlugin()
await router.push('/')
const icon = markRaw(adminNavigation[0].icon)
const items: AdminNavigationNode[] = [
{ key: 'home', title: '首页', icon, path: '/' },
{
key: 'resources', title: '资源管理', icon,
children: [{ key: 'warehouses', title: '仓库管理', icon, path: '/warehouses' }],
},
]
const wrapper = mount(AdminSidebarMenu, {
attachTo: rail,
props: { items, activePath: '/', collapsed: true },
global: { plugins: [router], components: { ElMenu, ElMenuItem, ElSubMenu }, stubs: { transition: false } },
})
try {
await flushPromises()
const menu = wrapper.get('.side-nav-menu.el-menu--collapse')
const group = wrapper.get('.side-nav-menu > .el-sub-menu')
const leafTrigger = wrapper.get('.el-menu-tooltip__trigger')
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('76px')
expect(getComputedStyle(rail).overflowX).toBe('hidden')
expect(getComputedStyle(rail).overflowY).toBe('auto')
expect(getComputedStyle(menu.element).width).toBe('100%')
for (const trigger of [leafTrigger, group.get('.el-sub-menu__title')]) {
expect(getComputedStyle(trigger.element).paddingLeft).toBe('0px')
expect(getComputedStyle(trigger.element).paddingRight).toBe('0px')
expect(getComputedStyle(trigger.element).justifyContent).toBe('center')
expect(getComputedStyle(trigger.get('.menu-icon').element).marginRight).toBe('0px')
}
await group.trigger('mouseenter')
await vi.waitFor(() => expect(group.attributes('aria-expanded')).toBe('true'))
const popup = document.body.querySelector('.shell-nav-popper .el-menu--popup')
expect(popup).not.toBeNull()
expect(popup?.closest('.side-nav')).toBeNull()
expect(popup?.textContent).toContain('仓库管理')
const destination = popup!.querySelector<HTMLElement>('.el-menu-item')!
destination.focus()
expect(document.activeElement).toBe(destination)
expect(destination.getAttribute('role')).toBe('menuitem')
destination.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/warehouses')
await wrapper.setProps({ collapsed: false })
host.classList.remove('is-collapsed')
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('240px')
expect(wrapper.find('.el-menu--collapse').exists()).toBe(false)
} finally {
wrapper.unmount()
style.remove()
host.remove()
}
})
it('renders only entries included in the filtered navigation input', () => {
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const wrapper = mount(AdminSidebarMenu, {
+1 -1
View File
@@ -10,7 +10,7 @@ defineProps<{
</script>
<template>
<el-sub-menu v-if="node.children?.length" :index="node.key">
<el-sub-menu v-if="node.children?.length" :index="node.key" popper-class="admin-workbench shell-nav-popper">
<template #title>
<component
v-if="!level"
+11
View File
@@ -44,6 +44,17 @@ describe('WorkspaceTabs', () => {
})
afterEach(() => { document.body.innerHTML = '' })
it('retains its own theme on the teleported management menu', async () => {
const { wrapper } = await mountTabs()
await wrapper.get('[aria-label="页签管理"]').trigger('click')
await flushPromises()
const popper = document.body.querySelector('.shell-header-popper')
expect(popper?.classList.contains('admin-workbench')).toBe(true)
expect(popper?.closest('.workspace-tabs')).toBeNull()
wrapper.unmount()
})
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
const { wrapper, workspace, unsaved } = await mountTabs()
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
+13 -13
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -125,7 +126,7 @@ watch(
</script>
<template>
<section class="workspace-tabs" aria-label="页面工作区">
<section class="workspace-tabs admin-workbench" aria-label="页面工作区">
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
<div
v-for="(tab, index) in workspace.tabs"
@@ -156,7 +157,7 @@ watch(
</div>
</div>
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
<el-dropdown trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper" @command="handleCommand">
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
<MoreHorizontal :size="18" aria-hidden="true" />
</el-button>
@@ -181,9 +182,8 @@ watch(
min-width: 0;
height: 44px;
align-items: stretch;
border-bottom: 1px solid var(--cc-border);
background: #f7f9fc;
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
border-bottom: 1px solid var(--workbench-border);
background: var(--workbench-surface);
}
.workspace-tab-list {
display: flex;
@@ -208,10 +208,10 @@ watch(
border: 1px solid transparent;
border-bottom: 0;
border-radius: 8px 8px 0 0;
color: #526178;
color: var(--workbench-muted);
}
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
.workspace-tab:hover { background: var(--workbench-surface-soft); color: var(--workbench-text); }
.workspace-tab.is-active { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.workspace-tab-activate {
display: flex;
min-width: 0;
@@ -239,19 +239,19 @@ watch(
border: 0;
border-radius: 6px;
background: transparent;
color: #7b8799;
color: var(--workbench-muted);
cursor: pointer;
}
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
.workspace-tab-close:hover { background: var(--workbench-accent-border); color: var(--workbench-primary); }
.workspace-tab-activate:focus-visible,
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
.workspace-tab-close:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--workbench-border); color: var(--workbench-muted); }
@media (max-width: 720px) {
.workspace-tabs { top: 64px; height: 48px; }
.workspace-tab-list { align-items: center; padding: 4px 6px; }
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
.workspace-tab.is-active { border-color: #cbd7e7; }
.workspace-tab.is-active { border-color: var(--workbench-accent-border); }
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
.workspace-tab-more { height: 48px; }
}
+22 -2
View File
@@ -114,7 +114,8 @@ describe('AppLayout', () => {
it('shows the authenticated identity and the localhost full-access control', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.get('.user-copy').text()).toBe('平台管理员(admin)')
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
const accessToggle = wrapper.get('[role="switch"]')
expect(accessToggle.text()).toContain('本地全权限')
expect(accessToggle.text()).toContain('已关闭')
@@ -130,10 +131,29 @@ describe('AppLayout', () => {
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
it('avoids repeating identical display names and accounts while retaining the full accessible identity', async () => {
const { wrapper } = await mountLayout()
const auth = useAuthStore()
auth.user = { ...auth.user!, name: 'admin', account: 'admin' }
await wrapper.vm.$nextTick()
expect(wrapper.get('.user-copy').text()).toBe('admin')
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:admin(admin)')
expect(wrapper.get('.user-copy').attributes('title')).toBe('登录身份:admin(admin)')
})
it('limits the shell theme to navigation and header instead of inheriting it into routed pages', async () => {
const { wrapper } = await mountLayout('/customers')
expect(wrapper.get('.app-sidebar').classes()).toContain('admin-workbench')
expect(wrapper.get('.app-header').classes()).toContain('admin-workbench')
expect(wrapper.get('.cached-count').element.closest('.admin-workbench')).toBeNull()
})
it('hides the shell heading when the route opts out', async () => {
const { wrapper } = await mountLayout('/customers')
expect(wrapper.find('.page-heading').exists()).toBe(false)
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
})
it('logs out after confirmation', async () => {
+21 -18
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -26,9 +27,12 @@ const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const isMobileViewport = ref(false)
let mobileViewportMedia: MediaQueryList | undefined
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const userIdentity = computed(() => {
const name = auth.user?.name, account = auth.user?.account
return name && account && name !== account ? `${name}(${account})` : name || account || '—'
})
const userIdentityLabel = computed(() => `登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const menuButtonLabel = computed(() => {
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
@@ -139,7 +143,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<template>
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
<aside class="app-sidebar">
<aside class="app-sidebar admin-workbench">
<div class="brand-block">
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
@@ -150,7 +154,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
</aside>
<div class="app-main">
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<header class="app-header admin-workbench" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<button
class="shell-menu-button"
type="button"
@@ -158,13 +162,12 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading">
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div v-if="showPageHeading" class="page-heading" aria-hidden="true" />
<div class="header-user">
<button
v-if="access.localFullAccessAvailable"
class="local-full-access-toggle"
class="local-full-access-toggle workbench-tone"
:data-tone="access.localFullAccessEnabled ? 'green' : 'blue'"
type="button"
role="switch"
:aria-checked="access.localFullAccessEnabled"
@@ -175,7 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<span>本地全权限</span>
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
</button>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
</button>
@@ -186,7 +189,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
</el-dropdown-menu></template>
</el-dropdown>
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
<div class="user-copy" :aria-label="userIdentityLabel" :title="userIdentityLabel"><strong>{{ userIdentity }}</strong></div>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
</div>
</header>
@@ -209,14 +212,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-muted); font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); }
.local-full-access-toggle:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-accent); }
.local-full-access-toggle strong { color: inherit; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.notification-trigger:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
.notification-trigger:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
.notification-trigger > svg { flex: 0 0 auto; }
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed } from 'vue'
import { ShieldX } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -16,7 +17,7 @@ async function returnToAccessiblePage() {
</script>
<template>
<section class="forbidden-page" aria-labelledby="forbidden-title">
<section class="forbidden-page admin-workbench" aria-labelledby="forbidden-title">
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
<p class="section-kicker">403 · 权限不足</p>
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
+24 -6
View File
@@ -8,6 +8,22 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
})
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
]))
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
})
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
@@ -31,16 +47,16 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
@@ -196,13 +212,15 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
it('shows the restored supplier entry under general only with its list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).map(item => item.path)).toEqual(['/dashboard'])
})
it('shows split container workbenches without a standalone smart-loading menu', () => {
+5 -3
View File
@@ -134,8 +134,9 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-outsourced-business', title: '外配任务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
},
@@ -147,6 +148,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
],
},
{
@@ -204,7 +206,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
@@ -0,0 +1,33 @@
# Admin 预约日历模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
## 页面与入口
| 目标 | 入口 | API 与后端 |
|---|---|---|
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
## 关键边界
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
## 验证
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./batchApi', async original => ({ ...await original<typeof import('./ba
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
@@ -176,7 +178,7 @@ it('retries a new destination without exposing the prior batch or retaining its
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.findAll('input[data-picker-kind="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
@@ -1,4 +1,6 @@
<script setup lang="ts">
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -121,13 +123,13 @@ onMounted(() => load())
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
<form class="density-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`" class="workbench-input"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()" class="workbench-input"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" class="workbench-input" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></div></form>
</template>
</template>
</section>
</template>
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--workbench-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
@@ -0,0 +1,149 @@
import ElementPlus from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import { batchContext } from './batch.test-support'
import type { ScheduleEvent } from './schedule'
import { getWorkDetail, type WorkAction } from './workApi'
import { workDetail, workItem } from './work.test-support'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals() })
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
beforeEach(() => {
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
})
async function open() {
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
} } })
await flushPromises()
return wrapper
}
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
const content = creationBody()
await content.get('input[type="checkbox"]').setValue(true)
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
return dialog
}
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
expect(dialog.props('width')).toBe('520px')
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
})
it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
picker.vm.$emit('update:modelValue', null); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(saveWarehouseArrival).not.toHaveBeenCalled()
})
it('keeps complete long cargo names accessible in the selectable table', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
expect(resolvePlanningSource).not.toHaveBeenCalled()
})
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
const wrapper = await open(); const dialog = await create(wrapper)
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
expect(fields.props('continuous')).toBe(true)
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(date.props('modelValue')).toBe('2026-10-25')
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
expect(creationBody().text()).toContain('AP-001')
})
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
const wrapper = await open(); await create(wrapper)
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
const receipt = creationBody().get('[aria-label="预约保存回执"]')
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
})
it('does not open or request creation data without its original write permission', async () => {
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
})
it('uses the same real continuous form through the original work detail deep link', async () => {
routing.query.workItem = 'work-delivery-1'
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
await content.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
expect(content.text()).toContain('本次保存已完成')
})
@@ -2,18 +2,54 @@ import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { scheduleTypes, type ScheduleEvent } from './schedule'
import { projectTripCalendar, type TripCalendarEntry } from './tripCalendar'
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
function reportedDispatch() {
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
}
describe('four-type appointment calendar', () => {
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
const wrapper = render(reportedDispatch(), '2026-10-23')
const search = wrapper.get('[aria-label="搜索当前日历"]')
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
await search.setValue(keyword)
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无安排')
expect(wrapper.get('[data-date="2026-10-23"]').findAll('.event-chip')).toHaveLength(0)
expect(wrapper.get('[data-date="2026-10-22"]').findAll('.event-chip')).toHaveLength(1)
await wrapper.get('[data-date="2026-10-22"]').trigger('click')
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
expect(wrapper.get('.detail-event').text()).toContain('09:00')
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
})
it('does not match an unrelated container and can find the same event by the dispatch number', async () => {
const wrapper = render(reportedDispatch(), '2026-10-22')
await wrapper.get('[aria-label="搜索当前日历"]').setValue('UNRELATED9999')
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
await wrapper.get('[aria-label="搜索当前日历"]').setValue('PC-CALENDAR-001')
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
expect(wrapper.findAll('.event-chip')).toHaveLength(1)
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
})
it('shows all four types by default with a calendar and persistent daily detail', () => {
const wrapper = render(scheduleTypes.map(type => event(type)))
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
expect(wrapper.findAll('.calendar-day')).toHaveLength(42)
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
})
it('filters locally without requesting another range', async () => {
@@ -24,6 +60,15 @@ describe('four-type appointment calendar', () => {
expect(wrapper.text()).toContain('当天暂无安排')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('offers creation in the header only when authorized', async () => {
const wrapper = render()
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
await wrapper.setProps({ canCreate: true })
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([[]])
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
})
it('changes the selected date locally and requests one range per mode or period change', async () => {
const wrapper = render()
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
@@ -63,7 +108,8 @@ describe('four-type appointment calendar', () => {
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
expect(wrapper.text()).not.toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -1,12 +1,14 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode }>(),
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month' })
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent] }>()
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
@@ -36,15 +38,16 @@ function changeMode(next: CalendarMode) {
}
</script>
<template>
<section class="appointment-calendar admin-density" :aria-busy="loading">
<div class="calendar-main">
<section class="appointment-calendar admin-density admin-workbench" :aria-busy="loading">
<div class="calendar-main workbench-panel is-flush">
<header class="calendar-toolbar">
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
</div>
<div class="calendar-controls">
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
<div class="view-toggle workbench-tabs" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
<strong class="calendar-period">{{ heading }}</strong>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
@@ -53,7 +56,7 @@ function changeMode(next: CalendarMode) {
</header>
<div class="calendar-options">
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input class="workbench-input" v-model="keyword" type="search" maxlength="128" placeholder="柜号、排车单号、仓库" aria-label="搜索当前日历"></label>
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
</div>
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
@@ -66,10 +69,10 @@ function changeMode(next: CalendarMode) {
</button>
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
@@ -82,35 +85,35 @@ function changeMode(next: CalendarMode) {
</template>
<style scoped>
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-main, .calendar-detail { overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-label > svg { color: var(--workbench-primary); }.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-search { margin-left: auto; }.calendar-search input { width: 190px; }
.view-toggle { display: flex; }
.nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
.undated-button[aria-pressed='true'] { background: var(--workbench-surface); color: var(--workbench-primary); font-weight: 600; }.nav-button { border: 1px solid var(--workbench-border); font-size: 22px; }
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: var(--workbench-muted); background: var(--workbench-surface-soft); padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: var(--workbench-surface-soft); }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: 8px; background: var(--workbench-surface); text-align: left; cursor: pointer; color: inherit; font: inherit; }
.calendar-day.outside { background: var(--workbench-surface-soft); color: var(--workbench-muted); }.calendar-day:hover { border-color: var(--workbench-accent-border); }.calendar-day.selected { border-color: var(--workbench-primary); background: var(--workbench-accent-soft); }
button:focus-visible, input:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: var(--workbench-primary); }
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.detail-event + .detail-event { border-top: 1px solid var(--workbench-border); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid var(--workbench-border); padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave } from 'vue-router'
@@ -5,7 +6,7 @@ import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
import { listTripCalendar, type TripCalendarEntry } from './tripCalendar'
import type { ScheduleEvent } from './schedule'
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
@@ -15,12 +16,20 @@ vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
}
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '<section data-calendar />' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
} } })
}
@@ -32,6 +41,30 @@ beforeEach(() => {
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
})
describe('simplified appointment entry', () => {
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(true)
calendar.vm.$emit('create'); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
expect(panel.props('canWrite')).toBe(true)
vi.mocked(getCalendarSources).mockClear()
panel.vm.$emit('created'); await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(1)
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
routing.denied.add(permission)
const wrapper = renderSchedule(); await flushPromises()
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(calendar.props('canCreate')).toBe(false)
calendar.vm.$emit('create'); await flushPromises()
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
wrapper.unmount()
})
it('keeps unsaved input when the operator declines to leave', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const wrapper = renderSchedule(); await flushPromises()
@@ -45,7 +78,7 @@ describe('simplified appointment entry', () => {
})
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
@@ -60,10 +93,10 @@ describe('simplified appointment entry', () => {
wrapper.unmount()
})
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限')
expect(wrapper.text()).toContain('缺少排车读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
@@ -81,7 +114,7 @@ describe('simplified appointment entry', () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
@@ -115,4 +148,34 @@ describe('simplified appointment entry', () => {
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
routing.denied.add('platform:fleet:trip:list')
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] })
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
expect(listTripCalendar).toHaveBeenCalledOnce()
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', event); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
const good = dispatchEntry()
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch')
expect(dispatches).toHaveLength(1)
expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' })
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(panel.props('incomplete')).toBe(true)
expect(wrapper.find('.data-notice').exists()).toBe(false)
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
expect(listTripCalendar).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
@@ -1,4 +1,6 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
@@ -23,7 +25,10 @@ import { appointmentExternalTarget } from './workNavigation'
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const creating = ref(false)
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
function initialDate() {
const value = typeof route.query.date === 'string' ? route.query.date : ''
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
@@ -32,7 +37,8 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const hasUnresolvedTrips = ref(false)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
@@ -41,6 +47,7 @@ async function load(next = range.value) {
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
hasUnresolvedTrips.value = false
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
@@ -61,12 +68,12 @@ async function load(next = range.value) {
} catch (cause) { fail('计划到仓读取失败', cause) }
})(),
(async () => {
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
try {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
hasUnresolvedTrips.value = result.unresolved.length > 0
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
@@ -124,12 +131,14 @@ async function canLeave() {
if (saving.value || unknownResult.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
catch { return false }
}
return true
}
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
@@ -163,21 +172,30 @@ onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
onBeforeUnmount(() => { ++generation; controller?.abort() })
</script>
<template>
<AdminPage class="admin-density appointment-schedule" spacing="compact">
<AdminPage class="admin-density appointment-schedule admin-workbench" spacing="compact">
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<template v-if="editor">
<p>{{ editor.title }}</p>
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
</AppDialog>
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<div v-if="editor" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</template>
</div>
</AppDialog>
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
@@ -186,7 +204,5 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
</template>
<style scoped>
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
</style>
@@ -43,7 +43,7 @@ async function save() {
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
<el-form label-position="top" :disabled="saving || !!pending">
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
</el-form>
</AppDialog>
</template>
@@ -0,0 +1,98 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
const access = vi.hoisted(() => ({ read: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
function render(active = true, disabled = false) {
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
} } })
}
beforeEach(() => {
vi.clearAllMocks(); access.read = true
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
vi.mocked(getCargoVerification).mockResolvedValue(context)
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
})
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled()
await wrapper.setProps({ active: true }); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('does not ask for known materials to be entered again', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
await wrapper.setProps({ active: false })
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
expect(await wrapper.vm.canLeave()).toBe(false)
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(await wrapper.vm.canLeave()).toBe(false)
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
})
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
})
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
const wrapper = render(); await flushPromises()
expect(wrapper.find('[role=alert]').exists()).toBe(true)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
vi.clearAllMocks(); access.read = false
const denied = render(); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
})
it('keeps the supplement action disabled for read-only appointment views', async () => {
const wrapper = render(true, true); await flushPromises()
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import CargoVerificationForm from './CargoVerificationForm.vue'
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
import type { LeaveGuard } from './useWorkDraft'
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
const emit = defineEmits<{ ready: [value: boolean] }>()
const access = useAccessPreviewStore()
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
let generation = 0
const guards = new Set<LeaveGuard>()
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
const unregister = register?.(canLeave)
defineExpose({ canLeave })
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
function issues(row: Row) {
const missing: string[] = []
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
return missing
}
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
watch(ready, value => emit('ready', value), { immediate: true })
async function load() {
const current = ++generation
loading.value = true; error.value = null
try {
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
}
const result: Row[] = []
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
for (const lookup of props.sources) {
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
if (current !== generation) return
const cargo = await getCargoVerification(resolved.source)
if (current !== generation) return
result.push({ resolved, cargo })
}
if (current === generation) rows.value = result
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
finally { if (current === generation) loading.value = false }
}
async function closeEditor() {
if (editor.value && !await editor.value.canLeave()) return
editing.value = null
}
async function saved() { editing.value = null; await load() }
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
onBeforeUnmount(() => { ++generation; unregister?.() })
</script>
<template>
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
</tr>
</tbody></table>
<div v-if="editingRow">
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
</div>
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。</p>
</section>
</template>
@@ -87,7 +87,6 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
</template>
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
@@ -0,0 +1,139 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
const guards = new Set<LeaveGuard>()
function render(canWrite = true) {
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
},
} })
}
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
await flushPromises()
const fields = wrapper.findComponent({ name: 'FbaAppointmentFields' })
if (fields.exists()) {
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
fields.vm.$emit('confirmationReady', true)
}
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks(); guards.clear()
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
})
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
const wrapper = render(); await flushPromises()
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
expect(destination().props?.filterable).toBe(true)
expect(listPlanningDestinations).not.toHaveBeenCalled()
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
open(true); await flushPromises()
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
wrapper.unmount()
})
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
const wrapper = render(); await flushPromises()
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
wrapper.unmount()
})
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
await selectAndPrepare(wrapper)
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
expect(wrapper.text()).not.toContain(cargo.orderId)
wrapper.unmount(); expect(guards.size).toBe(0)
})
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
await wrapper.get('[data-date]').setValue('2026-10-25')
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
expect(wrapper.find('[data-date]').exists()).toBe(false)
expect(wrapper.get('[aria-label="预约保存回执"]').text()).toContain('等待对方确认')
expect(wrapper.get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
wrapper.unmount()
})
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
wrapper.unmount()
})
it('disables blocked cargo and keeps selected cargo across pages', async () => {
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
const wrapper = render(); await flushPromises()
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
wrapper.unmount()
})
it('reports source failures and does not offer a write path without permission', async () => {
const readonly = render(false); await flushPromises()
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
wrapper.unmount()
})
it('ignores a source resolution that arrives after the dialog is closed', async () => {
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
})
@@ -0,0 +1,161 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
import type { SourceReference } from './workApi'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
let destinationsRequest = 0, destinationsController: AbortController | null = null
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
options: destinations.value.map(code => ({ label: code, value: code })),
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
if (visible && !destinationsLoaded.value) void loadDestinations()
} } },
])
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
const selectedRows = computed(() => Object.values(selected.value))
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
return ''
}
function toggle(row: PlanningCargo, checked: boolean | string | number) {
if (!props.canWrite || resolving.value) return
if (!checked) delete selected.value[key(row)]
else if (!reason(row)) selected.value[key(row)] = row
selectionError.value = null
}
async function load(nextPage = page.value) {
if (!props.canWrite) return
page.value = nextPage
const current = ++generation; controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
if (current === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
} finally { if (current === generation) loading.value = false }
}
function search() {
keyword.value = String(query.value.keyword || '').trim()
destination.value = String(query.value.destination || '').trim()
void load(1)
}
async function loadDestinations() {
if (!props.canWrite || destinationsLoading.value) return
const current = ++destinationsRequest
destinationsController?.abort(); destinationsController = new AbortController()
destinationsLoading.value = true; destinationError.value = null
try {
const result = await listPlanningDestinations(destinationsController.signal)
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
} catch (cause) {
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
}
async function prepare() {
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
resolving.value = true; selectionError.value = null
try {
const sources: SourceReference[] = []
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
for (const row of snapshot) {
if (current !== selectionGeneration) return
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
if (current !== selectionGeneration) return
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
}
if (current === selectionGeneration) references.value = sources
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
finally { if (current === selectionGeneration) resolving.value = false }
}
async function back() { if (await props.canLeave()) references.value = null }
function saved(appointment: FbaAppointmentDto) {
created.value = appointment; references.value = null; selected.value = {}
emit('created', appointment)
}
async function again() {
if (!await props.canLeave()) return
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
await load()
}
onMounted(() => { void load() })
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
</script>
<template>
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
<template v-if="created">
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
<table class="density-table"><tbody>
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template>
<template v-else-if="references">
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
</template>
<template v-else>
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
<div class="workbench-table"><DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
</DataTable></div>
</section>
</template>
</section>
</template>
@@ -105,10 +105,10 @@ onBeforeUnmount(() => { ++generation })
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
</el-form>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
@@ -58,7 +58,7 @@ onMounted(load)
</template>
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
</el-form>
<div v-if="canWrite && detail.impact.status === 'OPEN'">
@@ -1,4 +1,7 @@
<script setup lang="ts">
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -7,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ source: CargoVerificationSource }>()
const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<SaveCargoVerification | null>(null)
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
defineExpose({ canLeave })
const canWrite = computed(() => !props.disabled && access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
async function load(preserve = false) {
loading.value = true; error.value = null
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
@@ -23,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source)
if (!preserve) {
const record = context.value.verification
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
baseline.value = JSON.stringify(form)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -36,6 +40,7 @@ async function save() {
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
}
@@ -53,19 +58,20 @@ onMounted(() => load())
</script>
<template>
<section aria-label="货物核实资料">
<h3>核实货物资料</h3><p>未约成也可保存;只用于预约核实,保留原订单资料。</p>
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<form class="density-form work-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" /></label>
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
<LoadErrorState v-if="saveError" title="核实资料未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></form>
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></div></form>
</template>
</section>
</template>
@@ -1,5 +1,59 @@
# 预约工作日历逐操作矩阵
## 2026-10-21 排车候选分页
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|---|---|---|---|
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
## 2026-10-20 日期时间组件统一(当前确认)
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
| 页面/组件 | 操作与承载 | 本次调整 | 验收 |
|---|---|---|---|
| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 真实浏览器检查通过:日期面板、选值、空值禁用、全屏保值、未保存保护及放弃返回;未提交业务保存 |
| WorkReceivingPanel | 原业务深链中的客户计划、后送与跟进,原表单 | 5处日期/日期时间输入;日期与秒精度的当地时间分别保存;调整开始/结束时段后仍清除原时差依据 | 自动检查通过;待人工截图确认 |
| WorkCommandForm | 跟进、收货要求,原表单 | 3处日期、2处时间输入;跨日结束日期显式保留;不推断次日、不转换地点时区 | 自动检查通过;待人工截图确认 |
| AppointmentBatchForm | 原分批/合并表单 | 2处可空目标日期,保留跨批分配、版本、幂等与原写权限 | 自动检查通过;待人工截图确认 |
共4组件13处。日期使用 `el-date-picker`,时间使用 `el-time-picker`;保留原权限、处理中及未知结果锁定、清空与未保存保护。新增控件显式接收原禁用条件,宽度复用 `density-form`,不新增全局日期控件外观规则。
自动验证:受影响7个测试文件88项通过;类型检查与生产构建通过。全量回归执行464个文件4203项,4201项通过,两项旧公共样式断言(标签宽度、追加样式类)更新后,该样式文件65项全部复验通过。后台业务模板原生日期/时间控件残留检查通过。本轮未部署TEST,未进行真实HTTP或人工页面截图验收。
补充浏览器验收:用户明确要求打开页面后,使用已发布TEST页面,页面报告1920×1080、DPR1、visual scale1。TSTU5446539的默认计划、Element日期面板、快捷选值、全屏保值、清空禁用、未保存保护与放弃返回通过;原计划仍2026-10-01,未点击保存。MSCU7264017现已有入库计划,按冻结时间显示且无调整入口。本轮仅覆盖日历与调整到仓弹框,其余三表单仍待逐页操作;输入09:30的最终确认未单独完成,不计通过。证据见 `.logs/appointment-element-release-20261020/ui/result.json` 及同目录截图。
## 2026-10-20 蓝白工作台续批(写权已协调)
本次续批修改文件(相对本模块):
- `AppointmentBatchForm.vue`
- `AppointmentCalendarPanel.vue`
- `AppointmentCalendarView.vue`
- `AppointmentCreatePanel.vue`
- `ReceivingScope.vue`
- `SourceAppointmentPanel.vue`
- `TripAppointmentPanel.vue`
- `WorkDetailPanel.vue`
- `WorkReceivingPanel.vue`
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|---|---|---|
| 预约日历主页面及原业务深链的实际表单、工作详情;保留四类、月周日期与原弹框承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
当前无正式入口引用的11个旧面板保留原样:AppointmentCancellationDialog、AppointmentCancellationHistory、AppointmentCancellationRecords、AppointmentCargoPanel、AppointmentCargoTabs、AppointmentContainerPanel、AppointmentExternalCandidates、AppointmentFollowupDialog、AppointmentImpactDialog、AppointmentInboundCodes、WorkCalendarPanel。不是新增或恢复入口;TripAppointmentPanel仍由正式车次详情引用,已纳入本次续批。
## 2026-10-20 排车入口收敛
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
## 2026-10-19 四类日历恢复(当前结论)
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
@@ -7,22 +61,41 @@
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|---|---|---|---|
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 |
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
2026-10-16 本次 UI 检查:用户重新要求检查后,运行 Admin 全量测试,397 文件 / 3292 项通过;发现并修正 QueryToolbar 必填 `ariaLabel` 属性的模板写法。补充真实 KeepAlive + Teleport 离开/恢复及点击提醒弹层回归后,最新定向检查 3 文件 / 66 项通过,最终 `pnpm typecheck`、`pnpm build` 和 `git diff --check` 通过。构建仍有既有 AnalyticsChart 约 588 kB 分块提示。此记录更新下文历史“未运行验证”的结论;本地自动检查不代表测试环境或人工视觉验收。所属客户显示缺项仍待后端合同补齐,INP 采集报错的脚本来源未确认,本次未部署。日志为 `.logs/appointment-ui-current-{test,focused,typecheck,build}.log`。
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
2026-10-16 操作列样式核对:用户截图 `codex-clipboard-9d54345c-c9c1-4991-82ba-b319948f781b.png` 明确要求与外层表格统一。
| 页面 | 操作 | 截图依据与布局 | 当前实现 | 本轮结论 |
|---|---|---|---|---|
| 预约工作明细表格 | 查看详情或服务端提供的下一步操作 | 操作列内蓝色普通文字,无边框、底色或装饰图标 | `RowActions` 默认描边样式 | 使用公共 `text-actions` 模式,保留当前文案、权限、点击承载和长文案换行;待人工截图确认 |
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
@@ -60,6 +133,7 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|---|---|---|
| 事项列表统一表格 | 用户截图 `codex-clipboard-1bde1991-b226-4956-8530-6de44074f818.png` 明确要求统一表格或卡片,不能混排 | 所有日期/类型/全局待办列表统一使用 DataTable 与 RowActions,替代早期多行卡片排版。操作固定最左;事项、类型、状态、作业时间、时区、流程、地点、负责人、下次跟进、提醒分列,时间保留实际/确认/目标口径。列表弹框加宽,表头与分页由共享表格承载;保留每页 8 项、筛选和返回上下文。当前调整覆盖旧卡片与独立分页结论;未运行验证或部署,待人工截图确认 |
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
@@ -161,8 +235,12 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| 本次操作 | 证据与现状 | 调整结论 |
|---|---|---|
| 未定日期范围内搜索 | 用户截图 `codex-clipboard-07b5ab03-e467-4ce1-8410-13d05ac5bc51.png` 反馈大量数据缺少搜索,且测试环境仍显示旧卡片 | 全局待办弹框接入 QueryToolbar,支持柜号、批次号、车次号、地点关键词;沿现有 items API 同时传 view(含 UNDATED)与 keyword,服务端全范围筛选后分页,不过滤当前 8 条。搜索/清空回第 1 页,返回明细保留搜索词和分页,原业务跳转的 return URL 保留 workListKeyword。保留全局统计口径;表格与搜索均为当前工作区改动,本任务未部署,未运行验证 |
| 所属客户缺失排查 | 用户截图 `codex-clipboard-645f5354-0dfd-4b90-b6f7-05ff9b1f2ffb.png`;`BusinessReference` 只有 kind/key/businessNo/label,客户引用存在 `CUSTOMER + 内部客户键 + null + 所属客户` 的生产路径,详情查询仅合并引用 | 前端按 `businessNo || '—'` 展示,缺少真实客户名称;不能据此认定没有关联客户,也不能回退内部客户键。待工作事项后端按权威客户目录补充名称/业务编号并明确展示合同;遵循开发协作契约,本任务不保留后端改动。未完成接口或环境验收 |
| 提醒列与跳转遮罩 | 用户截图 `codex-clipboard-54e0b56d-448c-40da-a147-15798c932091.png` 和按钮反馈 | 提醒列固定 105px,仅显示去重后的条数,点击展开完整可滚动提醒,不撑高表格行。预约页被 KeepAlive 停用时卸载挂到 body 的弹框,恢复时保留父级事项/分页上下文。用户 startTime 截图实际是 INP 采集的空 entries 读取,脚本来源尚待确认,不能将该报错认定为已修复;本轮未运行验证或部署 |
| 事项详情的信息层级 | 用户 2026-10-16 截图 `codex-clipboard-cad4abc6-51d1-42f2-8e7b-7914b264348e.png` 反馈排版不清楚、缺少重点 | 顶部突出事项名称与状态;作业时间、时区、地点、负责人和下次跟进复用共享描述表;原有提醒去重后集中展示,保留完成态与跟进日期语义;当前操作与关联资料/历史分区,保留所有动作资格、原业务跳转、默认历史展开条件和未保存保护。空原业务进度不渲染空标题;已知事件代码使用中文名称,未知代码保留在辅助信息中,时间保留原时区。本轮未运行验证或部署,待人工截图确认 |
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
| 工作明细左侧长操作按钮 | Admin #621 真实 1920×1080 截图 `baiyue-firstleg/reports/appointment-browser-20261015/admin-621-follow-today-1920.png`;按钮与文本实际行高仅 13px | 保留完整文案和 108px 操作列,统一使用 20px 行高、自然换行和自适应高度;普通/展开面板及主操作/查看详情使用同一规则 |
| 工作明细左侧长操作按钮 | 用户 2026-10-16 截图 `codex-clipboard-b36b905a-a223-4fa6-98ca-7ece08149a37.png` 反馈按钮过大;原 108px 列宽、全宽实心按钮导致长文案换行撑高 | 操作列调整为 184px,按钮按文案宽度使用共享 `density-action` 描边样式和 28px 最小高度;完整保留服务端动作标签及点击行为,超长文案仍可换行。业务编号使用深色标题,辅助信息使用说明字号,行间距收紧。适用全部工作明细弹框及全屏态,覆盖早期实心主按钮结论;改后待人工截图确认,本轮未运行验证或部署 |
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
@@ -186,10 +264,22 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
### 2026-10-21 弹框表单可用性修复
依据:本轮弹框可用性反馈;沿用本文件原有逐操作承载,不调整业务字段与提交逻辑。
| 页面/文件 | 操作与承载 | 结构问题与修复 | 验收 |
|---|---|---|---|
| WorkCommandForm、AppointmentBatchForm、WorkReceivingPanel、CargoVerificationForm | 预约工作弹框中的命令、批次、收货范围与货物核实 | 原生控件未命中 Element 表单样式;显式复用 workbench-input,并通过 appointmentNativeForms.css 补齐标签/控件排列、fieldset 间距及底部提交区 | 定向回归通过;待人工1920×1080、100%截图确认 |
| WorkAppointmentForm、WorkDispatchForm | 预约取消、按预约批次排车 | 取消原因与计划托位原生控件接入同一局部表单样式;复选框与既有候选分页保留 | 定向回归通过;待人工截图确认 |
本次6个Vue仅变更样式导入、class与操作区容器;与修改前快照逐项核对,业务脚本、v-model、事件、权限/禁用条件均一致。类型检查、全量回归与构建由总控统一收口。
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import type { ReceivingMember, ReceivingTransport } from './receivingApi'
defineProps<{ members: ReceivingMember[]; transport?: ReceivingTransport }>()
</script>
@@ -12,4 +13,4 @@ defineProps<{ members: ReceivingMember[]; transport?: ReceivingTransport }>()
<template v-if="transport"><p v-for="(vehicle, index) in transport.vehicles" :key="index">车辆:{{ vehicle.tripNo || '车次未关联' }} · 排车单 {{ vehicle.dispatchNo || '未关联' }} · {{ vehicle.packageCount }} 箱</p><p v-for="reason in transport.blockReasons" :key="reason" role="status">{{ reason }}</p><p v-if="!transport.vehicles.length">当前未关联车辆。</p></template>
</div>
</template>
<style scoped>.receiving-member { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }</style>
<style scoped>.receiving-member { border-bottom: 1px solid var(--workbench-border); padding-block: var(--admin-space-3); }</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, onMounted, ref } from 'vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -38,4 +39,4 @@ onMounted(load)
</template>
</section>
</template>
<style scoped>.source-appointment { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }</style>
<style scoped>.source-appointment { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-block: var(--admin-space-3); border-bottom: 1px solid var(--workbench-border); }</style>
@@ -10,7 +10,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, recordVersion: 0, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, onMounted, onBeforeUnmount, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -90,7 +91,7 @@ onBeforeUnmount(() => { ++generation })
</template>
</article>
<p v-if="readable && !loading && !cards.length">当前没有可读取的预约批次引用。</p>
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density admin-workbench is-dialog" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
<template v-if="selected">
<WorkReceivingPanel v-if="mode === 'receiving'" :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :initial-customer-id="selected.customerId" :can-write="canWrite" @updated="updated" @appointment="openAppointment" @batch="key => open(key, selected!.customerId, 'batch')" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="mode === 'batch'" :key="selected.batchKey" :batch-key="selected.batchKey" @updated="updated" @appointment="openAppointment" />
@@ -100,4 +101,4 @@ onBeforeUnmount(() => { ++generation })
</AppDialog>
</section>
</template>
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }</style>
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--workbench-border); }</style>
@@ -9,7 +9,7 @@ vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn()
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', mounted() { this.$emit('confirmationReady', true) }, props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
@@ -49,14 +49,16 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
})
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物分配仍保留')
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
expect(model.appointmentStatus).toBe('SUCCESS'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
Object.assign(model, { confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
wrapper.getComponent({ name: 'FbaAppointmentFields' }).vm.$emit('confirmationReady', true)
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'SUCCESS', plannedDate: null, confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00', appointmentEndAt: null }) }))
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
@@ -1,4 +1,6 @@
<script setup lang="ts">
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -11,6 +13,8 @@ import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
@@ -26,7 +30,7 @@ async function load(preserve = false) {
try {
detail.value = await getFbaAppointment(props.appointmentNo)
if (!preserve || !initialized) {
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'SUCCESS'
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
}
}
@@ -42,6 +46,9 @@ async function save() {
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
} else {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
}
}
@@ -57,7 +64,7 @@ async function save() {
onMounted(() => load())
</script>
<template>
<section class="work-form" aria-label="预约安排">
<section class="work-form appointment-native-form" aria-label="预约安排">
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取原预约…</p>
@@ -67,8 +74,8 @@ onMounted(() => load())
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
@@ -57,7 +57,7 @@ it('shows the actual cross-month interval and hides empty-day zeros while keepin
})
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
const wrapper = render()
expect(wrapper.findAll('.work-day')).toHaveLength(42)
expect(wrapper.findAll('.work-day')).toHaveLength(35)
const day = wrapper.get('[data-date="2026-10-15"]')
expect(day.get('.day-heading').text()).toContain('2 项')
await day.get('[data-type="DELIVERY"]').trigger('click')
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkCommandForm from './WorkCommandForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
let guard: LeaveGuard
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
} } })
@@ -79,7 +81,7 @@ const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记
it('preserves an explicit cross-day local interval in the requirement request', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -93,7 +95,7 @@ it.each([
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -102,7 +104,7 @@ it.each([
it('saves a date-only requirement with all time fields null', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
await wrapper.findAll('input[type=date]')[0]!.setValue('2026-10-15')
await wrapper.findAll('input[data-picker-kind=date]')[0]!.setValue('2026-10-15')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
wrapper.unmount()
@@ -1,4 +1,7 @@
<script setup lang="ts">
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -85,20 +88,20 @@ async function save() {
}
</script>
<template>
<form class="density-form work-form" @submit.prevent="save">
<h3>{{ action.label }}</h3>
<form class="density-form work-form appointment-native-form" @submit.prevent="save">
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label><p>跟进日期不代替作业日期,未完成事项仍保留在待办中。</p></template>
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label><p>若原安排需要改期,请先处理原安排,再记录核对结果。</p></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow" class="workbench-input"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" class="workbench-input" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" class="workbench-input" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient" class="workbench-input"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200" class="workbench-input"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers" class="workbench-input"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError" class="workbench-input"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required class="workbench-input"><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" class="workbench-input" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
</fieldset>
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button>
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button></div>
</form>
</template>
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>
@@ -18,22 +18,29 @@ beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPla
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物备妥窗口待核实')
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
wrapper.unmount()
})
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
async function confirmRegistration(wrapper: ReturnType<typeof render>) {
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-16T09:30:00' })
fields.vm.$emit('confirmationReady', true)
await flushPromises()
}
it('previews and creates the exact canonical external source and customer with real confirmation', async () => {
const wrapper = render(); await flushPromises()
await confirmRegistration(wrapper)
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'PENDING' }) }))
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) }))
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount()
})
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises()
const wrapper = render(); await flushPromises(); await confirmRegistration(wrapper); await wrapper.get('button').trigger('click'); await flushPromises()
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
expect(await guard()).toBe(false)
await wrapper.get('button').trigger('click'); await flushPromises()
@@ -42,6 +49,7 @@ it('retains the same immutable request after an unknown outcome and blocks leavi
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
await confirmRegistration(wrapper)
await wrapper.get('button').trigger('click'); await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,12 +11,15 @@ import type { SourceReference } from './workApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import { workReason } from './workPresentation'
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SourceAppointmentPayload | null>(null)
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
const visibleMissingFields = computed(() => preview.value?.missingFields.filter(field => field !== 'READY_WINDOW_UNVERIFIED') || [])
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
async function load() {
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
@@ -29,13 +32,16 @@ async function save() {
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
if (!pending.value) {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { originWarehouseId: preview.value.group.originWarehouseId, fbaWarehouseCode: preview.value.destinationWarehouseCode, sources: preview.value.sources.map(source => ({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID() }
}
saving.value = true; saveError.value = null
try {
const result = await createSourceAppointment(pending.value)
pending.value = null; baseline.value = JSON.stringify(form)
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result)
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
if (rejectedCommand(cause)) { pending.value = null; await load() }
@@ -44,18 +50,22 @@ async function save() {
onMounted(load)
</script>
<template>
<section class="work-form" aria-label="新建预约安排">
<h3>新建预约安排</h3>
<section class="work-form density-dialog-sections" aria-label="新建预约安排">
<LoadErrorState v-if="error" title="预约货物预览失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对本次货物范围…</p>
<template v-if="preview && !error">
<p>{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}</p>
<p v-for="source in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</p>
<p v-for="field in preview.missingFields" :key="field">{{ workReason(field) }}</p>
<p v-if="preview.group.readinessIncomplete">货物备妥窗口待核实。</p>
<FbaAppointmentFields :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<section class="density-dialog-section" data-tone="summary" aria-label="本次预约货物">
<h3 class="density-dialog-section__title">本次预约货物</h3>
<table class="density-table"><tbody>
<tr><th scope="row">起运仓</th><td>{{ preview.originWarehouseName || '—' }}</td><th scope="row">目的FBA仓</th><td>{{ preview.destinationWarehouseCode || '—' }}</td></tr>
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
</tbody></table>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
</section>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -21,6 +22,14 @@ import { useWorkSyncRead } from './useWorkSyncRead'
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
const attentionReasons = computed(() => [...new Set([
...(detail.value?.item.blockReasons || []),
...(detail.value?.item.missingFields || []),
...(detail.value?.item.overdueReasons || []),
].map(workReason))])
const needsFollowup = computed(() => !!detail.value && detail.value.item.executionStatus !== 'COMPLETED' && (!detail.value.item.owner || !detail.value.item.followupDate))
const historyLabels: Record<string, string> = { BASELINE_OBSERVED: '同步原业务状态', GENERATED: '创建工作事项', CONFIRM_ROUTE: '确认路线', SAVE_FOLLOWUP: '记录跟进', RESOLVE_REVIEW: '处理复核', SAVE_REQUIREMENT: '保存预约要求', HANDOFF_RETURN: '交接还空责任', ACCEPT_RETURN: '确认接手还空' }
function historyTime(value: string) { return value.replace('T', ' ').replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/, '').replace(/Z$/, ' UTC') }
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
const actionNotice = ref('')
const saveCompleted = ref(false)
@@ -105,17 +114,33 @@ onMounted(async () => {
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<div :aria-busy="loading">
<div class="work-detail" :aria-busy="loading">
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
<template v-if="detail">
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
<p class="followup-summary">下次跟进:{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'"> · 作业日期未定,跟进日不作为作业日期</span></p>
<p v-if="detail.item.executionStatus !== 'COMPLETED' && (!detail.item.owner || !detail.item.followupDate)" class="work-warning">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</p>
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
<header class="detail-overview">
<div class="detail-title"><h3>{{ detail.item.title }}</h3><el-tag :type="detail.item.executionStatus === 'COMPLETED' ? 'success' : detail.item.reviewRequired ? 'warning' : 'info'" size="small">{{ workStatus(detail.item) }}</el-tag></div>
<p class="detail-category">{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}</p>
</header>
<dl class="density-fields detail-facts" aria-label="事项关键信息">
<div><dt>作业时间</dt><dd>{{ workDate(detail.item) }}</dd></div>
<div><dt>作业时区</dt><dd>{{ detail.item.timezone || '地点时区待核实' }}</dd></div>
<div><dt>作业地点</dt><dd>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }}</dd></div>
<div><dt>负责人</dt><dd>{{ detail.item.owner?.name || '待分派' }}</dd></div>
<div class="followup-summary"><dt>下次跟进</dt><dd>{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'" class="field-note">作业日期未定,跟进日不作为作业日期</span></dd></div>
</dl>
<div v-if="detail.item.containers.length" class="container-links"><span>关联货柜</span><el-button v-for="container in detail.item.containers" :key="container.containerBookingId" class="density-action" data-tone="primary" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</el-button></div>
<aside v-if="needsFollowup || attentionReasons.length" class="attention-panel" aria-label="事项提醒">
<h4>事项提醒</h4>
<ul>
<li v-if="needsFollowup">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</li>
<li v-for="reason in attentionReasons" :key="reason">{{ reason }}</li>
</ul>
</aside>
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
<section class="current-work" aria-label="当前处理">
<h4>{{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}</h4>
<p v-if="detail.item.executionStatus === 'COMPLETED'" class="completion-summary">当前事项已完成,可查看关联工作和历史记录。</p>
<template v-if="!action && !continuation">
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
@@ -133,15 +158,43 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
<section v-else-if="action?.target.kind === 'READ_ONLY' && action.target.references.length" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
</section>
<section class="detail-records" aria-label="关联资料与历史">
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button class="density-action" @click="emit('open', related.workItemKey)">查看</el-button></div><p v-if="!detail.relatedItems.length">暂无关联工作</p></details>
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p><p v-if="!detail.references.length">暂无原业务记录</p></details>
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录 · {{ detail.history.length }} 条</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><div class="history-heading"><b>{{ historyLabels[entry.action] || '事项记录' }}</b><time :datetime="entry.occurredAt">{{ historyTime(entry.occurredAt) }}</time></div><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }}<span v-if="!historyLabels[entry.action]"> · {{ entry.action }}</span></small></div><p v-if="!detail.history.length">暂无记录</p></details>
</section>
</template>
</div>
</template>
<style scoped>
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
.work-detail { font-size: var(--admin-font-body); line-height: 1.6; color: var(--el-text-color-primary); }
.detail-overview { margin-bottom: var(--admin-space-5); }
.detail-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.detail-title h3 { margin: 0; font-size: var(--admin-font-title); }
.detail-category { margin: var(--admin-space-1) 0 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-detail .detail-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.followup-summary { grid-column: 1 / -1; }
.field-note { margin-left: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.container-links, .more-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.container-links { margin-top: var(--admin-space-3); }
.container-links > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.attention-panel { margin-top: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--el-color-warning-light-7); border-radius: var(--admin-dialog-section-radius); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); }
.attention-panel h4, .current-work > h4 { margin: 0 0 var(--admin-space-1); font-size: var(--admin-font-section); }
.attention-panel ul { margin: 0; padding-left: var(--admin-space-6); font-size: var(--admin-font-caption); }
.current-work { margin-top: var(--admin-space-5); padding-block: var(--admin-space-5); border-top: 1px solid var(--workbench-border); }
.current-work > p { margin: var(--admin-space-2) 0; }
.completion-summary { color: var(--el-color-success-dark-2); }
.detail-records { border-top: 1px solid var(--workbench-border); }
.detail-records > details { border-bottom: 1px solid var(--workbench-border); }
summary { cursor: pointer; padding-block: var(--admin-space-3); font-weight: 600; }
summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
.detail-records p { margin: var(--admin-space-1) 0; }
.related-row, .history-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); }
.related-row, .history-entry { border-bottom: 1px solid var(--workbench-border); padding-block: var(--admin-space-3); }
.related-row:last-child, .history-entry:last-child { border-bottom: 0; }
.related-row p, .history-entry small, .history-heading time { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.work-warning { color: var(--el-color-warning-dark-2); }
</style>
@@ -1,146 +1,197 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
const access = vi.hoisted(() => ({ allowed: true, denied: [] as string[] }))
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
let guard: LeaveGuard
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const batch = 'FBA_APPOINTMENT:1'
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
function detail(no = 'DS/001', version = 1): DispatchDetail {
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
}
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
function render(props: { batchKeys?: string[]; canWrite?: boolean } = {}) { return mount(WorkDispatchForm, { props: { batchKeys: [batch], canWrite: true, ...props }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true } } }) }
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
it.each([0, '0', '9007199254740993'])('preserves authoritative allocation version %s from the existing batch provider', async recordVersion => {
const value = row(); value.allocations[0]!.recordVersion = recordVersion
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [value], blockReasons: [] })
const wrapper = render()
try {
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
const value = row(); value.allocations[0]!.recordVersion = recordVersion as unknown as number
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 0, candidates: [value], blockReasons: [] })
const wrapper = render()
try { await flushPromises(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选'); expect(createDispatchDraft).not.toHaveBeenCalled() }
finally { wrapper.unmount() }
})
beforeEach(() => {
vi.clearAllMocks(); access.allowed = true
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
vi.mocked(listDispatchDrafts).mockResolvedValue([])
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; access.denied = []
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
vi.mocked(convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: [{ draftContainerId: 'container-1', dispatch: proof(body.groups[0]!.dispatch.idempotencyKey) }] }))
vi.mocked(getDispatch).mockImplementation(async no => detail(no)); vi.mocked(listDispatchDrafts).mockImplementation(async () => [clone(draft)])
vi.mocked(releaseDispatchDraft).mockImplementation(async () => ({ ...clone(draft), status: 'RELEASED', containers: [], sources: [] }))
})
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('DS-EXISTING')
expect(wrapper.text()).not.toContain('创建本客户排车单')
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
await button(wrapper, '查看原排车单').trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
wrapper.unmount()
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { total: 1, page: 1, size: 20, pages: 1, candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
const wrapper = render()
try {
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
} finally { wrapper.unmount(); get.mockRestore() }
})
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS/EXISTING', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
for (let index = 0; index < 2; index++) {
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
})
it('fails closed when transport relations cannot be read', async () => {
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
vi.mocked(getReceivingContext).mockResolvedValueOnce(receiving(batch, [{ dispatchNo: 'DS-OLD', batchKey: batch, customerId: 'customer-b', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
const mismatch = render(); await flushPromises(); expect(mismatch.text()).toContain('排车单与工作批次关联不一致'); expect(mismatch.text()).not.toContain('核对本批剩余可排实物'); expect(createDispatchDraft).not.toHaveBeenCalled(); mismatch.unmount()
})
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
wrapper.unmount()
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
expect(wrapper.text()).toContain('DS-EXISTING')
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
})
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
wrapper.unmount()
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
})
it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click')
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
})
it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
const wrapper = render(); await flushPromises()
const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
expect(inputs[1]!.attributes('disabled')).toBeDefined()
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
wrapper.unmount()
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
})
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
expect(await guard()).toBe(false)
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
wrapper.unmount()
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
})
it('recovers a draft container from the same token when its creation response was lost', async () => {
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
expect(await guard()).toBe(false); wrapper.unmount()
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
})
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
it('retries the exact container command after a lost response instead of generating another container key', async () => {
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('does not read or write scoped cargo without all original permissions', async () => {
access.allowed = false; const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
})
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
})
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
access.allowed = false; const denied = render(); await flushPromises(); expect(getReceivingContext).not.toHaveBeenCalled(); denied.unmount()
access.allowed = true; access.denied = ['platform:overseas-warehouse:dispatch:create']; const readonly = render(); await flushPromises(); expect(button(readonly, '创建整车排车单').attributes('disabled')).toBeDefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); readonly.unmount()
})
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
})
it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
wrapper.unmount()
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
const first = row(), second = row('9007199254740994', 'customer-b')
natives = [candidate(first), candidate(second)]
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [first, second], blockReasons: [] })
vi.mocked(listDispatchCandidates).mockImplementation(async query => ({ total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [clone(natives[query.page === 2 ? 1 : 0]!)], complete: false, snapshotAt: '' }))
const wrapper = render()
try {
await flushPromises(); expect(wrapper.findAll('input[type=checkbox]')).toHaveLength(1)
await wrapper.get('input[type=checkbox]').setValue(true)
const state = wrapper.vm as unknown as { changeCandidatePage(page: number): void }
state.changeCandidatePage(2); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
expect(listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, warehouseTab: 'ALL', warehouseId: 'origin-1' }))
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
} finally { wrapper.unmount() }
})
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
vi.mocked(listDispatchCandidates).mockImplementation(async query => {
const value = clone(natives[0]!)
if (query.page === 2) value.selectionGroup.groupVersion = 'new-group-version'
return { total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [value], complete: false, snapshotAt: '' }
})
const wrapper = render()
try {
await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
;(wrapper.vm as unknown as { changeCandidatePage(page: number): void }).changeCandidatePage(2)
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('选货组组成已变化')
expect(createDispatchDraft).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
@@ -1,125 +1,250 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { useAuthStore } from '@/core/auth/auth'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import { getReceivingContext } from './receivingApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
const existingReady = ref(false)
const selectionBaseline = ref('[]')
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
function reasons(row: BatchDispatchCandidate) {
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
return [...row.blockReasons, ...(row.packageCount == null || row.packageCount <= 0 ? ['实物箱数待核实'] : []), ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(row.allocations.some(allocation => !props.batchKeys.includes(allocation.batchKey) || allocation.customerId !== row.customerId) ? ['候选不属于当前批次和客户范围'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
}
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
type ReadyRow = BatchRow & { native: Candidate }
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
let generation = 0
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
function reasons(row: BatchRow) { return row.blockReasons }
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
async function load() {
if (!readable.value || pending.value) return
loading.value = true; error.value = null; existingReady.value = false
if (!readable.value || pending.value || corrupted.value) return
const current = ++generation, batches = [...new Set(props.batchKeys)]
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
try {
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
const batches = [...new Set(props.batchKeys)]
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
const references: typeof existingDispatches.value = []
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
if (current !== generation) return
const references: typeof existing.value = []
receiving.forEach((context, index) => {
const batchKey = batches[index]!
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
}
})
existingDispatches.value = references
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
existing.value = references
if (references.length && !arrangingRemaining.value) return
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
selectionBaseline.value = JSON.stringify(selected.value)
}
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
finally { loading.value = false }
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
warehouseId.value = origins[0]!
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
if (current !== generation) return
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
contexts.value = batchCandidates; receivingContexts = receiving
await loadCandidatePage(true)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) working.value = false }
}
function select(row: BatchDispatchCandidate, checked: boolean) {
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
if (selected.value.includes(row.planPalletId)) return
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
selected.value = [...selected.value, row.planPalletId]
async function loadCandidatePage(autoSelect = false) {
const sequence = ++nativeGeneration, current = generation
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
if (sequence !== nativeGeneration || current !== generation) return
if (error.value) { rows.value = []; return }
try {
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
const candidate = matches.length === 1 ? matches[0]! : null
const sources = receivingContexts[index]!.batch.items
const blocks = [...context.blockReasons, ...row.blockReasons]
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
}))
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
baseline.value = JSON.stringify(selected.value)
}
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
}
function changeSelection(row: BatchDispatchCandidate, event: Event) {
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
function select(row: BatchRow, event: Event) {
const input = event.target as HTMLInputElement
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
else if (!selected.value.includes(row.key)) {
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
}
input.checked = selected.value.includes(row.key)
}
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
async function executeStep() {
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
command.attempted = true; persist()
try {
let value: DispatchDraft | ConvertedDraft
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
ensureOwner()
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
if (command.action === 'CONVERT') {
const converted = value as ConvertedDraft
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
request.converted = converted
} else {
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
if (command.action === 'CONTAINER') {
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
request.containerId = draft.containers[0]!.draftContainerId
}
if (command.action === 'LOCK') {
const selectedSource = command.body.source as DraftSelection['source']
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
const group = requireSelectionGroup(source)
assertSelectionGroupVersions([expected!.native], [source])
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
}
request.draft = draft
}
request.step = null; persist()
} catch (cause) {
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
persist(); throw cause
}
}
async function save() {
if (!writable.value || saving.value || loading.value || error.value || result.value) return
if (!pending.value) {
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
}
saving.value = true; saveError.value = null; rejected.value = false
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
saveError.value = null
try {
const request = pending.value
if (!request.containerId) {
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
ensureOwner()
if (!pending.value) {
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
}
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
request.submitting = true
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
result.value = response[0].dispatch; pending.value = null; selected.value = []
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
saving.value = true
const request = pending.value
while (!request.converted) {
if (!request.step) {
if (request.releasing) { step('RELEASE', common(request.draft!)) }
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
else {
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
else {
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { publishLooseboardDemand: true, idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
}
}
}
await executeStep()
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
}
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
ensureOwner(); validateCurrentDispatch(latest, proof)
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
finally { saving.value = false }
}
async function refreshRejected() {
if (!rejected.value || !pending.value || saving.value) return
saving.value = true
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
async function releaseRejected() {
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
saving.value = true; saveError.value = null
try {
ensureOwner(); const request = pending.value
if (!request.draft) { pending.value = null; persist(); return }
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
ensureOwner()
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
request.draft = owned; request.releasing = true; request.step = null; persist()
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
finally { saving.value = false }
if (pending.value?.releasing) await save()
else if (!pending.value) await load()
}
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
onMounted(load)
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
<template>
<section class="work-form" aria-label="按预约批次排车">
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
<section class="work-form appointment-native-form" aria-label="按预约批次创建整车排车">
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、客户与实物来源…</p>
<template v-if="existingDispatches.length && !loading && !error && !result">
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
<template v-if="existing.length && !loading && !error && !result && !pending">
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
</template>
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
<el-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</template>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
</section>
</template>
@@ -1,43 +1,42 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import { getDispatch } from '@/modules/overseas-warehouse'
const state = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
import { getDispatch, type DispatchDetail } from '@/modules/overseas-warehouse'
import { resolveBatchWork } from './workApi'
import { workDetail, workItem } from './work.test-support'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
it.each(['customer-b', undefined])('rejects FBA dispatch references outside the exact batch customer: %s', async customerId => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: 'DS-ORIGINAL', customerId }]); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.find('table').exists()).toBe(false)
wrapper.unmount()
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
it.each(['customer-b', undefined])('rejects a FBA reference outside the exact batch customer: %s', async customerId => {
const value = detail(); value.dispatchInfo.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', recordVersion: 0, sources: [] }]; vi.mocked(getDispatch).mockResolvedValue(value)
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: value.dispatchNo, customerId }]); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
})
it('reads shared dispatch once and verifies every FBA batch reference', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE', appointmentBatches: ['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, batchNo: `AP-${id}`, customerId: 'customer-a', sources: [] })) })
const wrapper = render(['1', '2'].map(id => ({ batchKey: `FBA_APPOINTMENT:${id}`, dispatchNo: 'DS-ORIGINAL', customerId: 'customer-a' }))); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
wrapper.unmount()
it('reads a shared whole dispatch once and verifies every nested batch and real customer', async () => {
const value = detail(); value.dispatchInfo.appointmentBatches = ['a', 'b'].map((customerId, index) => ({ batchKey: `FBA_APPOINTMENT:${index + 1}`, batchNo: `AP-${index + 1}`, customerId, recordVersion: 0, sources: [] })); vi.mocked(getDispatch).mockResolvedValue(value)
const wrapper = render(value.dispatchInfo.appointmentBatches.map(row => ({ batchKey: row.batchKey, customerId: row.customerId, dispatchNo: value.dispatchNo }))); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.findAll('tbody tr')).toHaveLength(1); expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
})
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
const wrapper = render(); await flushPromises()
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
await wrapper.findAll('button').find(button => button.text() === '继续安排本批车辆')!.trigger('click')
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
it('resolves the opaque dispatch batch in the work domain and opens the original independent detail', async () => {
const wrapper = render(); await flushPromises(); expect(resolveBatchWork).toHaveBeenCalledWith('DISPATCH:9007199254740999', undefined)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FORIGINAL']])
expect(wrapper.text()).not.toContain('9007199254740999'); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], status: 'PENDING_DEPARTURE' })
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
it('rejects mismatched authority or returned business identity instead of deriving an internal ID', async () => {
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'DISPATCH:9007199254740999', customerId: 'a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-OTHER' }) }), candidates: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.find('table').exists()).toBe(false); expect(wrapper.text()).toContain('关联不一致'); wrapper.unmount()
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.find('table').exists()).toBe(false); wrong.unmount()
})
it('opens an already bound real trip instead of preparing another one', async () => {
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', appointmentBatches: [], workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('继续安排本批车辆')
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
it('opens same-dispatch adding only under the server gate, and keeps already scanned cargo out of the old merge flow', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '为本车添加同仓货物').trigger('click')
expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props()).toMatchObject({ dispatchNo: 'DS/ORIGINAL', canWrite: true }); wrapper.unmount()
const value = detail(); value.stage = 'LOADING'; value.counts.pickedPackageCount = 1; value.actions.ADD_CARGO!.allowed = false; vi.mocked(getDispatch).mockResolvedValueOnce(value)
const scanned = render(); await flushPromises(); expect(scanned.text()).not.toContain('为本车添加同仓货物'); expect(scanned.text()).not.toContain('共车'); scanned.unmount()
})
it('does not read the dispatch without its original list permission', async () => {
state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
it('does not read dispatch or batch authority without the actual list permissions', async () => {
access.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -1,44 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { resolveBatchWork } from './workApi'
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
const access = useAccessPreviewStore(), rows = ref<DispatchDetail[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const addingNo = ref(''), addingBusy = ref(false)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list') && (!props.dispatches.some(row => row.batchKey.startsWith('DISPATCH:')) || access.can('platform:appointment:list')))
function path(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
function canEdit(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.actions.EDIT?.allowed === true }
function canAdd(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.stage === 'BEFORE_LOADING' && row.actions.ADD_CARGO?.allowed === true }
let generation = 0
async function load() {
const current = ++generation; emit('ready', false); rows.value = []; error.value = null
if (!readable.value) return
loading.value = true; error.value = null; emit('ready', false)
loading.value = true
try {
rows.value = await Promise.all([...new Set(props.dispatches.map(reference => reference.dispatchNo))].map(async dispatchNo => {
const row = await getDispatch(dispatchNo)
// 预约批次必须仍由这张原排车单持有同一客户的分配,不能仅凭展示业务号续办。
const matches = props.dispatches.filter(reference => reference.dispatchNo === dispatchNo).every(reference =>
reference.batchKey.startsWith('FBA_APPOINTMENT:')
? !!reference.customerId && row.status !== 'CANCELLED' && row.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId)
: `DISPATCH:${row.id}` === reference.batchKey)
if (!matches || row.dispatchNo !== dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
const references = props.dispatches.map(row => ({ ...row }))
const details = await Promise.all([...new Set(references.map(row => row.dispatchNo))].map(async no => {
const row = await getDispatch(no)
if (row.dispatchNo !== no || row.status === 'CANCELLED') throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
for (const reference of references.filter(value => value.dispatchNo === no)) {
let matches = false
if (/^FBA_APPOINTMENT:\d+$/.test(reference.batchKey)) {
matches = Boolean(reference.customerId && row.dispatchInfo.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId))
} else if (/^DISPATCH:\d+$/.test(reference.batchKey)) {
// The work domain resolves its opaque batch identity; Detail has no internal dispatch ID.
const resolution = await resolveBatchWork(reference.batchKey, reference.customerId)
matches = resolution.batchKey === reference.batchKey && (resolution.detail
? resolution.detail.item.batchKey === reference.batchKey && resolution.detail.item.batchNo === no && (!reference.customerId || resolution.customerId === reference.customerId)
: resolution.candidates.length > 0 && resolution.candidates.every(candidate => candidate.batchNo === no) && (!reference.customerId || resolution.candidates.some(candidate => candidate.customerId === reference.customerId)))
}
if (!matches) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
}
return row
}))
emit('ready', true)
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
finally { loading.value = false }
if (current !== generation) return
rows.value = details; emit('ready', details.length > 0)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '原排车单读取失败') }
finally { if (current === generation) loading.value = false }
}
function prepare(row: DispatchSummaryDto) { if (writable.value && !loading.value && !error.value && row.status === 'PENDING_DEPARTURE' && !row.workflow && row.overseasWarehouseId) { selected.value = row; prepareOpen.value = true } }
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
onMounted(load)
function add(row: DispatchDetail) { if (canAdd(row) && !loading.value && !error.value && !addingBusy.value) addingNo.value = row.dispatchNo }
function updated() { emit('updated'); void load() }
watch(() => props.dispatches, load, { immediate: true, deep: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>沿用本批已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">继续安排本批车辆</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
</section>
</template>
@@ -15,9 +15,10 @@ beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContai
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
const wrapper = render(); await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
await wrapper.findAll('button')[0]!.trigger('click')
const plans = wrapper.findAll('button').filter(item => item.text() === '查看原接仓记录')
await plans[0]!.trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
expect(plans[1]!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
})
it('does not read the container source without its original permission', async () => {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -42,7 +43,7 @@ async function load() { if (!readable.value) return; loading.value = true; error
onMounted(load)
</script>
<template>
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划</h3><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划<InlineHelp title="接仓计划说明"><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p></InlineHelp></h3><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
<LoadErrorState v-if="error" title="本柜入仓计划读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
@@ -26,6 +27,7 @@ function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreeme
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
@@ -234,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
return c
}
async function enterBPlan(wrapper: ReturnType<typeof render>) {
@@ -371,7 +373,7 @@ it('inherits unconfirmed legacy header plans only without active independent cov
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
for (const ownPlan of [false, true]) {
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
@@ -1,4 +1,7 @@
<script setup lang="ts">
import './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -140,7 +143,7 @@ async function openPlan(id: string) {
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
const saved = customerPlan(id)
const header = context.value!.batch
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : { ...createAppointmentForm(), appointmentStatus: 'PENDING' as const }
Object.assign(planForm, createAppointmentForm(), saved ? Object.fromEntries(Object.entries(saved.details).map(([key, value]) => [key, value ?? ''])) : { ...inherited, confirmationNo: '', appointmentAt: '', appointmentEndAt: '', startOffset: '', endOffset: '' })
baseline.value = state()
}
@@ -220,7 +223,7 @@ async function save(decision: 'CONFIRM' | 'WITHDRAW' = 'CONFIRM') {
const command = buildCommand(decision)
if (command.kind === 'group' && command.request.action === 'CANCEL') {
confirming.value = true
try { await ElMessageBox.confirm('确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。', '取消整份预约', { customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '返回核对' }) } catch { return } finally { confirming.value = false }
try { await ElMessageBox.confirm('确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。', '取消整份预约', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认取消', cancelButtonText: '返回核对' }) } catch { return } finally { confirming.value = false }
}
pending.value = command
} catch (cause) { saveError.value = { message: cause instanceof Error ? cause.message : '请核对本次范围' }; return }
@@ -263,7 +266,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<ReceivingScope :members="[]" :transport="context.transport" />
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
</template>
<form v-else @submit.prevent="save()">
<form v-else class="density-form appointment-native-form" @submit.prevent="save()">
<fieldset :disabled="locked || !formWritable">
<template v-if="mode === 'refresh'">
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
@@ -274,26 +277,26 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
</template>
<template v-if="mode === 'plan'">
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划</h4>
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划<InlineHelp title="客户计划说明"><p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p></InlineHelp></h4>
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
<p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p>
<label>目标送仓日期<el-date-picker v-model="planForm.plannedDate" type="date" aria-label="客户计划日期" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label>
<label>计划开始时段<el-date-picker v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime" aria-label="客户计划开始" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<label>计划结束时段<el-date-picker v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime" aria-label="客户计划结束" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00" class="workbench-input"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00" class="workbench-input"></label></details>
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态" class="workbench-input"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" class="workbench-input" /></label>
</template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数" class="workbench-input"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人" class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" class="workbench-input" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" class="workbench-input" /></label></template>
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" /></label></template>
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" class="workbench-input" /></label></template></template>
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" class="workbench-input" /></label></template>
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
</fieldset>
<el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button>
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button></div>
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
</form>
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
@@ -1,48 +1,73 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { getDispatch } from '@/modules/overseas-warehouse'
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
let guard: LeaveGuard
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
TripPreparationDialog: { name: 'TripPreparationDialog', props: ['modelValue', 'warehouseId', 'dispatchNos'], template: '<div />' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
} } }) }
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockImplementation(async number => ({ id: `internal-${number}`, dispatchNo: number, appointmentBatches: [], customerId: number === 'DS-60' ? 'customer-b' : 'customer-d', overseasWarehouseId: 'warehouse-real', overseasWarehouseName: '实际起运仓', packageCount: number === 'DS-60' ? 60 : 40, status: 'PENDING_DEPARTURE' })) })
it('checks exact 60 and 40 dispatch business numbers and passes both to the existing Fleet preparation before navigating to its real trip', async () => {
const wrapper = render()
expect(getDispatch).not.toHaveBeenCalled()
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(vi.mocked(getDispatch).mock.calls.map(call => call[0])).toEqual(['DS-60', 'DS-40'])
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
expect(prepare.props()).toMatchObject({ modelValue: true, warehouseId: 'warehouse-real', dispatchNos: ['DS-60', 'DS-40'] })
expect(wrapper.text()).toContain('60 箱'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).not.toContain('internal-')
expect(await guard()).toBe(false)
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-SHARED' } }); await flushPromises()
expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-SHARED']]); expect(wrapper.emitted('updated')).toHaveLength(1)
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, DispatchWorkflowDialog: { name: 'DispatchWorkflowDialog', props: ['modelValue', 'dispatchNo', 'action'], template: '<div data-add-cargo />' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}<button @click="$emit(\'retry\')">重试</button></p>' }, ApiErrorMeta: true } } }) }
function detail(no = 'DS-60', allowed = true) { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, counts: { packageCount: 60 }, vehicleInfo: { vehicle: null, driver: null }, actions: { ADD_CARGO: { allowed, blockCode: allowed ? null : 'PICKED', blockReason: allowed ? null : '货物已开始真实拣货' } } } as DispatchDetail }
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('opens the native add-cargo workflow for the same dispatch instead of asking for another customer dispatch', async () => {
const wrapper = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-60'); expect(wrapper.find('input').exists()).toBe(false)
expect(wrapper.text()).toContain('60 箱'); await button(wrapper, '选择同仓未安排货物').trigger('click')
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }); expect(dialog.props()).toMatchObject({ modelValue: true, dispatchNo: 'DS-60', action: 'ADD_CARGO' }); expect(await guard()).toBe(false)
dialog.vm.$emit('saved'); await flushPromises(); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true); wrapper.unmount()
})
it('honors native stage/gate and permissions and rejects a different returned business number', async () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail('DS-60', false), stage: 'LOADING' }); const scanned = render(); await flushPromises(); expect(button(scanned, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(scanned.text()).toContain('货物已开始真实拣货'); scanned.unmount()
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
})
it('ignores an old add command without blocking the calendar or another action',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=render();await flushPromises()
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(await guard()).toBe(true)
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
wrapper.unmount()
})
it('rejects duplicate, cross-warehouse, already-bound and mismatched dispatches without opening Fleet preparation', async () => {
const wrapper = render(); await wrapper.get('input').setValue('DS-60'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(getDispatch).not.toHaveBeenCalled()
for (const change of [{ overseasWarehouseId: 'another-warehouse' }, { workflow: { tripNo: 'TR-OTHER', dispatchReview: { status: 'PENDING' as const, reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING' as const, reason: null, reviewedAt: null } } }, { status: 'SHIPPED' }, { dispatchNo: 'WRONG' }]) {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: 'a', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE' }).mockResolvedValueOnce({ id: 'b', dispatchNo: 'DS-40', appointmentBatches: [], overseasWarehouseId: 'warehouse-real', status: 'PENDING_DEPARTURE', ...change })
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false); expect(wrapper.find('[role="alert"]').exists()).toBe(true)
}
it('retries lookup as a read and keeps the original detail navigation', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
})
it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.text()).toContain('来源已变化,请重新核对')
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
wrapper.unmount()
})
it('keeps a failed lookup retry read-only and respects original permissions', async () => {
const wrapper = render(); vi.mocked(getDispatch).mockRejectedValueOnce(new Error('读取失败'))
await wrapper.get('input').setValue('DS-40'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'TripPreparationDialog' }).exists()).toBe(false)
await wrapper.get('button').trigger('click'); await flushPromises()
expect(getDispatch).toHaveBeenCalledTimes(4); expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props('dispatchNos')).toEqual(['DS-60', 'DS-40'])
wrapper.unmount(); access.allowed = false
const readonly = render(); await readonly.get('button').trigger('click'); expect(getDispatch).toHaveBeenCalledTimes(4); readonly.unmount()
it('does not offer a stored command replay when opening the form again',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}}))
const wrapper=render();await flushPromises()
expect(wrapper.text()).not.toContain('刷新加货资料')
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
wrapper.unmount()
})
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
const wrapper = render(); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
expect(wrapper.text()).toContain('来源版本需重新核对')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('来源版本需重新核对')
expect(wrapper.emitted('updated')).toHaveLength(2)
wrapper.unmount()
})
@@ -1,50 +1,50 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
import { TripPreparationDialog } from '@/modules/overseas-master-data'
import { useAuthStore } from '@/core/auth/auth'
import { DispatchWorkflowDialog, DispatchInputError, getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
const access = useAccessPreviewStore(), otherNo = ref(''), rows = ref<DispatchSummaryDto[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), prepareOpen = ref(false)
const allowed = computed(() => props.canWrite && ['platform:overseas-warehouse:dispatch:list', 'platform:fleet:trip:list', 'platform:fleet:trip:edit'].every(permission => access.can(permission)))
watch(prepareOpen, value => emit('busy', value))
useWorkDraft(computed(() => false), prepareOpen)
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
watch(busy, value => emit('busy', value), { immediate: true })
useWorkDraft(computed(() => false), busy)
let generation = 0
function changed() { ++generation; loading.value = false; rows.value = []; error.value = null }
async function prepare() {
if (!allowed.value || loading.value || prepareOpen.value) return
rows.value = []; error.value = null
const other = otherNo.value.trim()
if (!other || other === props.dispatchNo) { error.value = { message: '请填写另一张已备好的排车单业务号,不能重复选择本单。' }; return }
const current = ++generation; loading.value = true
async function load() {
const current = ++generation; detail.value = null; error.value = null
if (!readable.value) return
loading.value = true
try {
const numbers = [props.dispatchNo, other]
const details = await Promise.all(numbers.map(getDispatch))
const value = await getDispatch(props.dispatchNo)
if (current !== generation) return
if (details.some((row, index) => row.dispatchNo !== numbers[index])) throw new Error('返回的排车单与所选业务号不一致,请重新核对。')
if (details.some(row => row.status !== 'PENDING_DEPARTURE' || row.workflow)) throw new Error('请选择尚未关联车次的待发运排车单;已关联的货物请从原车次继续处理。')
if (!details[0]!.overseasWarehouseId || details.some(row => row.overseasWarehouseId !== details[0]!.overseasWarehouseId)) throw new Error('两张排车单必须有明确且相同的起运仓。')
rows.value = details; prepareOpen.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '共车排车单核对失败') }
if (value.dispatchNo !== props.dispatchNo) throw new DispatchInputError('读取结果与原排车单不一致,请重新核对')
detail.value = value
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
finally { if (current === generation) loading.value = false }
}
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
onBeforeUnmount(() => { ++generation; emit('busy', false) })
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
watch(() => props.dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++; emit('busy', false) })
</script>
<template>
<section class="work-form" aria-label="与同仓其他排车单共车">
<h4>与同仓其他排车单共车</h4>
<p>本批排车单:{{ dispatchNo }}。请先按各客户实际托盘分别备好排车单,再核对同仓两单并安排同一车次。</p>
<p>所需箱数无法对应完整实物时,请先在原库存作业中完成拆托,再返回核对;分批计划不会代替实物拆托。</p>
<label>另一张排车单号<input v-model="otherNo" aria-label="共车排车单号" maxlength="64" :disabled="loading || prepareOpen || !allowed" @input="changed" @keydown.enter.prevent="prepare"></label>
<el-button :disabled="!allowed || prepareOpen" :loading="loading" @click="prepare">核对两单并安排共车</el-button>
<LoadErrorState v-if="error" title="共车排车单核对失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-for="row in rows" :key="row.dispatchNo">{{ row.dispatchNo }} · {{ row.overseasWarehouseName || '起运仓名称待核实' }} · {{ row.packageCount ?? '箱数待核实' }}<span v-if="row.packageCount != null"> 箱</span></p>
<TripPreparationDialog v-if="rows[0]?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="rows[0].overseasWarehouseId" :warehouse-name="rows[0].overseasWarehouseName || '起运仓名称待核实'" :dispatch-nos="rows.map(row => row.dispatchNo)" @saved="saved" />
<section class="work-form" aria-label="为原排车添加同仓货物">
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
</section>
</template>
@@ -0,0 +1,47 @@
/* Native work-command fields share the existing input theme, with local form layout. */
.appointment-native-form {
min-width: 0;
}
.appointment-native-form > fieldset {
display: grid;
min-width: 0;
gap: var(--admin-space-5);
margin: var(--admin-space-3) 0;
padding: 0;
border: 0;
}
.appointment-native-form > fieldset > .el-button { justify-self: start; }
.appointment-native-form label:has(> :is(input, select, textarea, .el-date-editor)) {
display: grid;
min-width: 0;
gap: var(--admin-space-2);
overflow-wrap: anywhere;
}
.appointment-native-form label > :is(.workbench-input, .el-date-editor) {
width: 100%;
min-width: 0;
max-width: 100%;
}
.appointment-native-form label > textarea {
resize: vertical;
}
.appointment-native-form label:has(> input[type='checkbox']) {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.appointment-native-form .appointment-form-actions {
position: sticky;
z-index: 1;
bottom: 0;
padding-block: var(--admin-space-3);
border-top: 1px solid var(--workbench-border);
background: var(--workbench-surface);
}
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
describe('appointment calendar', () => {
it('shows six complete Monday-first weeks across year boundaries', () => {
const dates = calendarDates('2027-01-15', 'month')
expect(dates).toHaveLength(42)
expect(dates[0]).toBe('2026-12-28')
expect(dates.at(-1)).toBe('2027-02-07')
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
it.each([
['2026-10-19', '2026-09-28', '2026-11-02', 35],
['2027-01-15', '2026-12-28', '2027-02-01', 35],
['2027-02-15', '2027-02-01', '2027-03-01', 28],
['2026-08-15', '2026-07-27', '2026-09-07', 42],
] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
const dates = calendarDates(anchor, 'month')
expect(dates).toHaveLength(count)
expect(dates[0]).toBe(from)
expect(dates.at(-1)).toBe(addDays(to, -1))
expect(calendarRange(anchor, 'month')).toEqual({ from, to })
})
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
+7 -4
View File
@@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string {
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
const date = parseDate(anchor)
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
if (mode === 'month') date.setDate(1)
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
const from = dateKey(date)
// A half-open interval also works directly with database timestamps.
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
// 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
}
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
const { from } = calendarRange(anchor, mode)
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
const { from, to } = calendarRange(anchor, mode)
const dates: string[] = []
for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
return dates
}
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
@@ -1,2 +1,4 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
export type { PlanningSourceLookup } from './planningApi'
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
@@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
expect(result.total).toBe(0)
})
it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
})
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
@@ -31,7 +31,7 @@ export interface PlanningCargo {
mixedDestinations: boolean
}
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
)
@@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
}
export async function listPlanningDestinations(signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
'/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
))
}
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
@@ -3,27 +3,39 @@ import { projectContainerSchedule } from './schedule'
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
const range = { from: '2026-11-01', to: '2026-12-01' }
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true, plannedDate: '2026-11-01', isDefault: true }
describe('four-type schedule projection', () => {
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, canEdit: false }], range, true)
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
})
it('adds two local calendar days across months without creating a time', () => {
const events = projectContainerSchedule(sources, [warehouse], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
})
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedDate: '2026-11-04' }], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1, isDefault: false }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
})
it('keeps manually moved containers visible when their arrival is outside this month', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
expect(events).toHaveLength(1)
expect(events[0]?.date).toBe('2026-11-08')
})
it('shows a date-only frozen plan as frozen without manufacturing midnight', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, planCount: 1, isDefault: false, canEdit: false }], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ date: '2026-11-01', time: '', subtitle: '已有入库计划,日期已冻结', canEdit: false })
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-01' })
})
it('keeps cross-month frozen date-only cards and their details readable without a port-arrival row', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedDate: '2026-11-08', planCount: 1, isDefault: false, canEdit: false }], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ date: '2026-11-08', time: '', title: 'D0001 计划到仓', subtitle: '已有入库计划,日期已冻结' })
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-08' })
})
it('does not replace the planned date with PDA actual arrival', () => {
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
expect(event.date).toBe('2026-11-05')
@@ -31,7 +43,7 @@ describe('four-type schedule projection', () => {
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
})
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], range, false)
expect(events.map(row => row.date)).toEqual([null, null])
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
+13 -11
View File
@@ -1,4 +1,4 @@
import { addDays } from './calendar'
import { projectWarehouseArrival } from '@/modules/container-loading'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
@@ -22,8 +22,9 @@ const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
const events: ScheduleEvent[] = []
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
const containerMap = new Map(sources.containers.map(row => [row.containerBookingId, row]))
for (const row of sources.containers) {
const label = row.containerNo || row.cabinetNo || '—'
const label = row.cabinetNo || '—'
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
@@ -36,31 +37,32 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
const projected = projectWarehouseArrival(warehouse)
const planned = projected.date
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : projected.isDefault ? '到港日期+2个自然日(默认)' : '人工维护',
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
}
// 人工改期可能跨月,与当前月份的到港记录无关。
for (const row of warehouseMap.values()) {
const date = row.plannedAt?.slice(0, 10) || null
const projected = projectWarehouseArrival(row)
const date = projected.date
if (!date || !inRange(date, range)) continue
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
title: `${row.cabinetNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
{ label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
}
for (const row of sources.emptyReturns) {
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
if (!at || !inRange(at.slice(0, 10), range)) continue
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { projectContainerSchedule } from './schedule'
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
vi.mock('@/shared/api/http', async original => ({
...await original<typeof import('@/shared/api/http')>(),
http: { get: vi.fn(), post: vi.fn() },
}))
const range = { from: '2026-09-28', to: '2026-11-02' }
const bookingId = '9007199254740993'
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
const wire = {
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
emptyReturns: [], emptyReturnsReadable: true,
} })
})
async function readSchedule(overrides: Record<string, unknown> = {}) {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
const sources = await getCalendarSources(range)
const warehouses = await getWarehouseArrivals(range, [bookingId])
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
}
describe('warehouse calendar response to schedule', () => {
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
})
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
expect(warehouses[0]?.planCount).toBe(2)
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
})
it('keeps a manually maintained time when there is no inbound plan', async () => {
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
})
it('does not calculate a default when existing plans have no unified time', async () => {
const { event } = await readSchedule({ planCount: '2', canEdit: false })
expect(event?.date).toBeNull()
})
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
const { event } = await readSchedule({ version: -1, canEdit: false })
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
})
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
},
)
})
@@ -21,6 +21,7 @@ export interface CalendarReturn {
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
export interface WarehouseArrival {
sourceContainerBookingId: string
cabinetNo?: string | null
containerNo: string | null
warehouseName: string | null
warehouseCode: string | null
@@ -29,17 +30,39 @@ export interface WarehouseArrival {
version: number
planCount: number
canEdit: boolean
plannedDate?: string | null
isDefault?: boolean
}
export interface ScheduleRange { from: string; to: string }
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
planCount: string | number
plannedAt?: string | null
actualAt?: string | null
}
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
const count = row.planCount
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
const planCount = Number(count)
if (!Number.isSafeInteger(planCount) || planCount < 0) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
}
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
))
}
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
))
return rows.map(normalizeWarehouseArrival)
}
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
@@ -0,0 +1,8 @@
const input = {
props: ['modelValue', 'disabled', 'type', 'format', 'valueFormat'],
emits: ['update:modelValue', 'change'],
template: `<input data-temporal-picker :data-picker-kind="type || 'time'" :value="modelValue" :disabled="disabled" @input="$emit('update:modelValue', $event.target.value); $emit('change', $event.target.value)">`,
}
export const DatePickerStub = { name: 'ElDatePicker', ...input }
export const TimePickerStub = { name: 'ElTimePicker', ...input }
@@ -0,0 +1,36 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { parse } from 'vue/compiler-sfc'
import { expect, it } from 'vitest'
/** 持续检查后台业务模板,避免再次用原生日期控件替代 Element 公共控件。 */
it('uses Element date and time pickers throughout Admin business templates', () => {
const root = join(process.cwd(), 'src/modules')
const violations: string[] = []
function inspect(content: DocumentFragment, file: string) {
for (const input of content.querySelectorAll('input, el-input')) {
const type = input.getAttribute('type') || ''
if (/^(date|time|month|week|datetime-local)$/.test(type)) {
violations.push(`${relative(root, file)} ${input.tagName.toLowerCase()} type=${type}`)
}
}
// 条件和插槽模板的内容位于独立的 DocumentFragment 中。
for (const template of content.querySelectorAll('template')) inspect(template.content, file)
}
function visit(directory: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const file = join(directory, entry.name)
if (entry.isDirectory()) visit(file)
else if (entry.name.endsWith('.vue')) {
const source = parse(readFileSync(file, 'utf8')).descriptor.template?.content
if (source) {
const template = document.createElement('template')
template.innerHTML = source
inspect(template.content, file)
}
}
}
}
visit(root)
expect(violations).toEqual([])
})
@@ -3,16 +3,38 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
expect(projected.events).toHaveLength(1)
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
const parts = Object.fromEntries(beijing.formatToParts(new Date(row.plannedDepartureAt!)).map(part => [part.type, part.value]))
expect([parts.year, parts.month, parts.day, parts.hour, parts.minute]).toEqual(['2026', '10', '23', '00', '00'])
expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-22', to: '2026-10-23' }).events).toHaveLength(1)
expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-23', to: '2026-10-24' }).events).toEqual([])
})
it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
expect(missing.events).toEqual([])
expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
})
it('counts a shared trip once and excludes cancelled arrangements', () => {
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-SHARED 排车' })
})
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
@@ -25,23 +47,23 @@ describe('location-based trip calendar', () => {
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
})
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
})
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
})
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
const november = {from:'2026-11-01',to:'2026-12-01'}
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
@@ -50,9 +72,9 @@ describe('location-based trip calendar', () => {
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
})
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
expect(result.events).toEqual([])
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
})
})
@@ -2,8 +2,9 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
tripNo: string
sourceType: 'DISPATCH'
dispatchNo: string
containerNumbers: string[]
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
@@ -23,6 +24,7 @@ export const tripIssueLabels: Record<string, string> = {
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
}
export function tripIssues(row: TripCalendarEntry) {
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
@@ -56,7 +58,13 @@ function localStamp(value: string | null, zone: string | null) {
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
const seen = new Set<string>()
for (const row of result.records) {
if (!row.dispatchNo?.trim()) {
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
}
if (seen.has(row.dispatchNo)) continue
seen.add(row.dispatchNo)
if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
@@ -65,14 +73,17 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
}
if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示',
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
{ label: '运输状态', value: labels[row.status] || '待核实' },
{ label: '关联柜号', value: row.containerNumbers.join('、') },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
{ label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') },
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
+28
View File
@@ -0,0 +1,28 @@
# Admin 操作日志与审计模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/audit/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 本模块**没有独立页面**:审计日志是客户详情里的内嵌面板(`components/CustomerAuditLogsPanel.vue`),查询接口在 `api/platformAudit.ts`。
- 后端查 `audit-service/`(`PlatformAuditLogController`、`AuditLogQueryService`);若是“操作人没被记录”,还要查采集侧 `common-audit/`(`BusinessAuditAspect.java`、`AuditEvent.java`)。
## 页面与入口
| 目标 | 入口 | API 与后端 |
|---|---|---|
| 客户详情内的操作日志面板、分页与详情 | `components/CustomerAuditLogsPanel.vue`(props:`customerId`、`customerName`) | `api/platformAudit.ts` 的 `listPlatformAuditLogs`、`getPlatformAuditLog`;`audit-service/` |
| 日志字段展示、时间与文案映射 | `presentation.ts` | 服务端返回结构 |
| 日志列表/详情的对外类型 | `index.ts` | `audit-service` DTO |
| 操作人缺失、字段为空 | 面板空态与列定义 | 先查 `common-audit/` 是否采集该字段,再查 `audit-service/` 摄取 DTO 与表结构 |
## 关键边界
- 列表不返回变更前后快照,详情才返回已脱敏 JSON;不要在列表里展开完整 payload。
- 客户账号不得访问平台审计查询;面板只在平台侧客户详情内使用。
- 审计字段缺失属于“采集侧漏采”或“查询侧未映射”,两者分属 `common-audit/` 与 `audit-service/`,不要只改前端展示。
- 审计链路故障不得阻塞核心交易,前端也不得据此推断业务失败。
## 验证
- 定向:`pnpm test src/modules/audit/<目标测试>`;字段或查询变化同时核对 `audit-service` 的查询接口与表结构。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
@@ -1,17 +1,22 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail } from '../api/platformAudit'
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
const detail: PlatformAuditLogDetail = { ...row, resourceId: '9007199254740993123', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' }
enableAutoUnmount(afterEach)
function mountPanel(permissions: string[]) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
@@ -20,7 +25,7 @@ function mountPanel(permissions: string[]) {
}
describe('CustomerAuditLogsPanel', () => {
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getPlatformAuditLog).mockResolvedValue(detail); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
it('queries logs with the current customer id', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
@@ -91,17 +96,24 @@ describe('CustomerAuditLogsPanel', () => {
})
it('loads detail and formats json snapshots', async () => {
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
const privateId = '9007199254740993123'
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, id: privateId }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, id: privateId, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
expect(getPlatformAuditLog).toHaveBeenCalledWith(privateId)
expect(wrapper.text()).toContain('旧名称')
expect(wrapper.text()).toContain('新名称')
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('入库测量')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).toContain('更新')
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
expect(wrapper.find('.audit-detail-table').exists()).toBe(true)
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '操作日志详情 · 柏悦物流', size: 'min(920px, 96vw)', showFooter: false })
expect(wrapper.find('.audit-detail-table').text()).toContain('req')
expect(wrapper.find('.audit-detail-table').text()).toContain('trace')
expect(wrapper.find('.audit-detail-table').text()).not.toContain(privateId)
expect(wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').attributes('aria-label')).not.toContain(privateId)
})
it('loads the selected audit page', async () => {
@@ -131,4 +143,100 @@ describe('CustomerAuditLogsPanel', () => {
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
it('preserves failure details and complete long or non-json snapshots as text', async () => {
const longText = '长内容'.repeat(1200)
const raw = '<script>原始内容</script>\n不是JSON'
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: '数据版本冲突', beforeData: JSON.stringify({ note: longText }), afterData: raw })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
const drawer = wrapper.findComponent(AppDrawer)
expect(drawer.text()).toContain('操作失败')
expect(drawer.get('[role="alert"]').text()).toContain('数据版本冲突')
const snapshots = drawer.findAll('.density-code-block')
expect(snapshots[0].text()).toBe(JSON.stringify({ note: longText }, null, 2))
expect(snapshots[1].text()).toBe(raw)
expect(drawer.find('script').exists()).toBe(false)
})
it('keeps missing snapshots compact and a zero duration visible', async () => {
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: null, errorCode: 'OPERATION_CONFLICT', durationMs: 0, beforeData: '', afterData: null, requestId: null, traceId: null })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
expect(wrapper.findAll('.density-code-block').map(snapshot => snapshot.text())).toEqual(['无', '无'])
expect(wrapper.find('.audit-detail-table').text()).toContain('0 ms')
expect(wrapper.find('.audit-detail-table').text()).not.toContain('链路标识')
expect(wrapper.find('.audit-detail-table').text()).toContain('OPERATION_CONFLICT')
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).toContain('操作失败,请查看诊断信息')
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).not.toContain('OPERATION_CONFLICT')
})
it('retries the selected log after a detail request failure', async () => {
vi.mocked(getPlatformAuditLog).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(detail)
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDrawer).text()).toContain('详情加载失败')
await wrapper.findComponent(AppDrawer).get('.load-error-state button').trigger('click'); await flushPromises()
expect(vi.mocked(getPlatformAuditLog).mock.calls).toEqual([['1'], ['1']])
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
})
it.each(['resolve', 'reject'] as const)('ignores a late %s from the previously selected log', async outcome => {
let finish!: (value: PlatformAuditLogDetail) => void
let fail!: (reason: Error) => void
const previous = new Promise<PlatformAuditLogDetail>((resolve, reject) => { finish = resolve; fail = reject })
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row, { ...row, id: '2', description: '第二条记录' }], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(getPlatformAuditLog).mockImplementation(id => id === '1' ? previous : Promise.resolve({ ...detail, id: '2', description: '第二条详情' }))
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
const actions = wrapper.findAll('.el-table__body button[aria-label^="查看操作日志:"]')
await actions[0].trigger('click'); await flushPromises()
await actions[1].trigger('click'); await flushPromises()
if (outcome === 'resolve') finish({ ...detail, description: '不应重新展示的旧详情' })
else fail(new Error('旧请求失败'))
await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('loading')).toBe(false)
expect(wrapper.findComponent(AppDrawer).text()).toContain('第二条详情')
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('不应重新展示的旧详情')
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
})
it('closes and clears pending detail when switching customers', async () => {
let finish!: (value: PlatformAuditLogDetail) => void
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
await wrapper.setProps({ customerId: '9', customerName: '另一客户' }); await flushPromises()
finish(detail); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
expect(listPlatformAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ accountId: '9' }))
})
it('ignores pending responses after closing and permits a fresh reopen', async () => {
let finish!: (value: PlatformAuditLogDetail) => void
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
wrapper.findComponent(AppDrawer).vm.$emit('update:modelValue', false)
await flushPromises()
finish({ ...detail, description: '过期详情' }); await flushPromises()
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
expect(getPlatformAuditLog).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('过期详情')
})
it('clears detail when audit permission is revoked', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
useAuthStore().permissions = []
useAccessPreviewStore().initializeAfterLogin()
await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
expect(wrapper.text()).toContain('暂无操作日志查看权限')
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
})
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { ref, watch } from 'vue'
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ScrollText } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -24,6 +25,9 @@ const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformAuditLogDetail | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const selectedLogId = ref<string | null>(null)
const detailTitle = computed(() => props.customerName.trim() ? `操作日志详情 · ${props.customerName.trim()}` : '操作日志详情')
let detailRequestSerial = 0
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
const filters = ref<QueryModel>(emptyFilters())
const appliedFilters = ref<QueryModel>(emptyFilters())
@@ -80,7 +84,7 @@ async function load() {
rows.value = []
total.value = 0
loadError.value = null
if (!customerId || !access.can('platform:audit:list')) return
if (!customerId || !access.can('platform:audit:list')) { loading.value = false; return }
loading.value = true
try {
const result = await listPlatformAuditLogs(toQuery(customerId))
@@ -104,17 +108,45 @@ function applyFilters(model: QueryModel) {
else page.value = 1
}
async function openDetail(row: PlatformAuditLogListItem) {
detailOpen.value = true
function clearDetail() {
detailRequestSerial += 1
selectedLogId.value = null
detailOpen.value = false
detailLoading.value = false
detail.value = null
detailError.value = null
}
async function loadDetail() {
const logId = selectedLogId.value
const customerId = props.customerId.trim()
if (!logId || !detailOpen.value || !access.can('platform:audit:list')) return
const serial = ++detailRequestSerial
const isCurrent = () => serial === detailRequestSerial && customerId === props.customerId.trim() && detailOpen.value
detailLoading.value = true
detail.value = null
detailError.value = null
try { detail.value = await getPlatformAuditLog(row.id) }
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
finally { detailLoading.value = false }
try {
const result = await getPlatformAuditLog(logId)
if (isCurrent()) detail.value = result
} catch (error) {
if (isCurrent()) detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试')
} finally {
if (isCurrent()) detailLoading.value = false
}
}
watch(() => props.customerId, () => {
async function openDetail(row: PlatformAuditLogListItem) {
selectedLogId.value = row.id
detailOpen.value = true
await loadDetail()
}
watch(detailOpen, open => { if (!open) clearDetail() })
onBeforeUnmount(() => { requestSerial += 1; detailRequestSerial += 1 })
watch([() => props.customerId, () => access.can('platform:audit:list')], () => {
clearDetail()
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void load()
@@ -126,48 +158,50 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
</script>
<template>
<section class="customer-audit-panel admin-density">
<div class="panel-title"><div><h2>操作日志</h2><p>{{ customerName }}已归属当前客户的档案与业务操作记录</p></div><ScrollText :size="19" /></div>
<section class="customer-audit-panel admin-density admin-workbench">
<div class="panel-title"><div><h2>操作日志</h2></div><ScrollText :size="19" /></div>
<div v-if="!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" /><strong>暂无操作日志查看权限</strong><p>请联系管理员开通平台操作日志查看权限。</p></div>
<template v-else>
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
<QueryToolbar v-model="filters" class="audit-query workbench-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
<div class="workbench-table" v-else><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志:${auditEventLabel((row as PlatformAuditLogListItem).eventType)},${formatDate((row as PlatformAuditLogListItem).occurredAt)}`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label workbench-badge is-toned">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
<template #username="{ row }">{{ auditOperatorLabel((row as PlatformAuditLogListItem).username) }}</template>
<template #serviceName="{ row }"><el-tooltip :content="display((row as PlatformAuditLogListItem).serviceName)" placement="top" :show-after="500"><span class="service-label">{{ auditServiceLabel((row as PlatformAuditLogListItem).serviceName) }}</span></el-tooltip></template>
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
<template #occurredAt="{ row }">{{ formatDate((row as PlatformAuditLogListItem).occurredAt) }}</template>
<template #pagination-left>共 {{ total }} 条客户操作记录</template>
</DataTable>
</DataTable></div>
</template>
<AppDrawer class="customer-audit-drawer admin-density-drawer" v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="audit-detail-hero" :class="detail.success ? 'is-success' : 'is-failed'">
<span class="result-icon" aria-hidden="true"><CircleCheck v-if="detail.success" :size="22" /><CircleX v-else :size="22" /></span>
<div class="hero-copy"><span>事件类型</span><h3>{{ auditEventLabel(detail.eventType) }}</h3><p>{{ auditDescriptionLabel(detail.description) }}</p></div>
<el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag>
<AppDrawer class="customer-audit-drawer admin-density-drawer admin-density-drawer--detail admin-workbench is-drawer" v-model="detailOpen" :title="detailTitle" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">{{ auditEventLabel(detail.eventType) }} <el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag></h3>
<p class="audit-event-description">{{ auditDescriptionLabel(detail.description) }}</p>
</section>
<section class="audit-detail-grid" aria-label="操作日志基础信息">
<div><span>操作人</span><strong>{{ auditOperatorLabel(detail.username) }}</strong></div>
<div><span>发生时间</span><strong>{{ formatDate(detail.occurredAt) }}</strong></div>
<div><span>来源服务</span><strong>{{ auditServiceLabel(detail.serviceName) }}</strong><small>{{ display(detail.serviceName) }}</small></div>
<div><span>客户端 IP</span><strong>{{ display(detail.clientIp) }}</strong></div>
<div><span>业务资源</span><strong>{{ auditResourceLabel(detail.resourceType) }}</strong><small>{{ display(detail.resourceId) }}</small></div>
<div><span>处理耗时</span><strong>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</strong></div>
<div class="wide request-cell"><span>请求地址</span><div><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></div></div>
<div class="wide identity-cell"><span>请求标识</span><strong>{{ display(detail.requestId) }}</strong><small v-if="detail.traceId">链路标识:{{ detail.traceId }}</small></div>
<section class="density-dialog-section workbench-evidence" data-tone="source">
<h3 class="density-dialog-section__title">操作信息</h3>
<table class="density-table audit-detail-table" aria-label="操作日志基础信息"><tbody>
<tr><th scope="row">操作人</th><td>{{ auditOperatorLabel(detail.username) }}</td><th scope="row">发生时间</th><td>{{ formatDate(detail.occurredAt) }}</td></tr>
<tr><th scope="row">来源服务</th><td>{{ auditServiceLabel(detail.serviceName) }}<small class="audit-service-code density-dialog-note">{{ display(detail.serviceName) }}</small></td><th scope="row">客户端 IP</th><td>{{ display(detail.clientIp) }}</td></tr>
<tr><th scope="row">业务资源</th><td>{{ auditResourceLabel(detail.resourceType) }}</td><th scope="row">处理耗时</th><td>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</td></tr>
<tr><th scope="row">请求地址</th><td colspan="3"><span class="audit-request"><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></span></td></tr>
<tr><th scope="row">请求标识</th><td colspan="3"><code>{{ display(detail.requestId) }}</code></td></tr>
<tr v-if="!detail.success && detail.errorCode"><th scope="row">错误码(诊断)</th><td colspan="3"><code>{{ detail.errorCode }}</code></td></tr>
<tr v-if="detail.traceId"><th scope="row">链路标识</th><td colspan="3"><code>{{ detail.traceId }}</code></td></tr>
</tbody></table>
</section>
<el-alert v-if="!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
<div class="snapshot-grid"><section><h3>操作前快照</h3><p>操作执行前保留的业务数据</p><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><p>操作执行后返回或保存的业务数据</p><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
</template>
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
<div class="audit-snapshots">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
</div>
</div>
</AppDrawer>
</section>
</template>
@@ -176,10 +210,15 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--workbench-border); }.panel-title h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--workbench-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:var(--workbench-muted); text-align:center; }.module-empty strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; overflow:hidden; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:var(--workbench-text); text-overflow:ellipsis; white-space:nowrap; }
.audit-event-description { margin:0; overflow-wrap:anywhere; }
.audit-detail-table { table-layout:fixed; }
.audit-detail-table th { width:92px; }
.audit-service-code { display:block; }
.audit-request { display:flex; align-items:center; gap:var(--admin-space-3); }
.audit-request code { min-width:0; }
.audit-snapshots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
.audit-snapshots > .density-dialog-section { margin-bottom:0; }
@media(max-width:720px) { .audit-snapshots { grid-template-columns:1fr; } }
</style>
+27
View File
@@ -0,0 +1,27 @@
# Admin 登录模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
- 模块只有一个页面 `pages/LoginView.vue`;认证状态、Token 刷新与路由守卫在 `../../core/auth/` 与 `../../shared/api/AGENTS.md`,本模块不复制这些逻辑。
- 后端只查 `auth/`(登录、刷新、退出、改密)。
## 页面与入口
| 目标 | 页面/入口 | API 与后端 |
|---|---|---|
| 平台员工登录页、表单校验与错误提示 | `pages/LoginView.vue` | `../../core/auth/auth.ts` → `../../shared/api/http.ts`;`auth/` |
| 登录态与路由跳转 | 页面不持有状态 | `../../core/auth/auth.ts`、`../../core/access/` |
| 401 刷新与重放 | 页面不处理 | `../../shared/api/http.ts`、`../../shared/api/AGENTS.md` |
## 关键边界
- 页面不读写 Token、不拼 `Authorization`、不自行持久化密码;令牌与刷新编排只在 `src/core/auth/` 与 `src/shared/api/http.ts`。
- 认证失败提示按服务端返回的 `code` 与文案展示,不把 401/403 混为同一种提示。
- 登录成功后跳转由路由守卫决定,页面不硬编码默认落地页。
- 记住账号类能力只保存登录标识,不持久化密码或 Token。
## 验证
- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或守卫变化同时核对 `src/core/auth/` 与 `src/shared/api/http.ts`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
+14
View File
@@ -0,0 +1,14 @@
# 平台登录页逐操作矩阵
依据:2026-10-20 用户确认全 Admin 推广清爽蓝白风格;视觉参考为工作区 `docs/界面参考/蓝白卡片式工作台风格参考.md` 与其中的用户参考图。当前页面入口为 `pages/LoginView.vue`;本轮只调整展示,不改变认证状态、接口和路由跳转。
| 页面 | 操作 | 当前承载与依据 | 本轮结论与验证 |
| --- | --- | --- | --- |
| 平台员工登录 | 输入账号、密码与提交 | `LoginView.vue` 的 `el-form` 与 `submit`;独立登录页,品牌区与表单左右布局 | 接入公共蓝白面板、图标和主题;保留原 `large` 表单尺寸、账号密码字段、自动完成、密码显隐与回车提交,不新增文案或字段 |
| 平台员工登录 | 必填校验与加载 | 原 `rules`、`loading` 与表单标签 | 原必填提示及提交加载保持;不增加验证码或改变认证步骤 |
| 平台员工登录 | 登录失败 | 原 `loginError`、`ApiErrorMeta` 与 `role="alert"` | 错误区域复用公共提示外观,服务端错误文案、诊断信息和可恢复表单保持 |
| 平台员工登录 | 登录成功后跳转 | 原 `auth.login`、`initializeAfterLogin` 与 `sanitizeInternalPath` | 成功提示、安全跳转、首个授权入口及无权限回退均保持,不修改 router/store |
| 平台员工登录 | 已有窄屏品牌布局 | 原 `960px`、`360px` 响应式规则 | 保留既有窄屏能力,不新增多视口验收范围 |
| 平台员工登录 | 详情、新增、编辑、审核、文件预览、删除 | 当前页面无上述操作 | 不新增操作或浮层 |
自动回归入口:`pages/LoginView.test.ts`。本模块未独立执行检查,统一由本轮总控完成。视觉待人工 `1920×1080`、浏览器缩放 `100%` 截图确认。
+9 -5
View File
@@ -43,14 +43,18 @@ describe('LoginView authentication lifecycle', () => {
expect(loginSource).not.toContain('demo-tip')
})
it('uses the admin design tokens and keeps the brand visible at 320px', () => {
expect(loginSource).toContain('<h1>柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand"')
it('uses the shared blue-white theme and preserves the existing compact brand layout', () => {
expect(loginSource).toContain("import '@/styles/adminWorkbench.css'")
expect(loginSource).toContain('class="login-page admin-workbench"')
expect(loginSource).toContain('<h1 class="workbench-title">柏悦物流管理后台</h1>')
expect(loginSource).toContain('class="login-mobile-brand workbench-brand-mark"')
expect(loginSource).toContain('class="login-card workbench-panel"')
expect(loginSource).toContain('class="login-error workbench-notice workbench-tone" data-tone="rose"')
expect(loginSource).toContain('aria-label="平台员工登录"')
expect(loginSource).toContain('label-position="top" size="large"')
expect(loginSource).toContain('autocomplete="username" maxlength="100" autofocus')
expect(loginSource).toContain('aria-live="polite"')
expect(loginSource).toContain('background: var(--cc-surface-muted)')
expect(loginSource).toContain('border-radius: var(--cc-radius-lg)')
expect(loginSource).not.toContain('var(--cc-navy)')
expect(loginSource).toContain('@media (max-width: 360px)')
expect(loginSource).not.toContain('让跨境物流管理')
})
+35 -29
View File
@@ -9,6 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import '@/styles/adminWorkbench.css'
const route = useRoute()
const router = useRouter()
@@ -54,25 +55,25 @@ async function submit() {
</script>
<template>
<div class="login-page">
<div class="login-page admin-workbench">
<section class="login-intro">
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div>
<h1>柏悦物流管理后台</h1>
<p class="intro-copy">平台内部运营与管理系统</p>
<div class="login-brand workbench-brand-mark"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="workbench-heading">
<h1 class="workbench-title">柏悦物流管理后台</h1>
<p class="login-intro-copy workbench-note">平台内部运营与管理系统</p>
</div>
<div class="security-note">
<ShieldCheck :size="22" />
<div class="login-security-note workbench-meta">
<ShieldCheck :size="22" aria-hidden="true" />
<span>平台内部系统,请使用授权账号登录</span>
</div>
</section>
<section class="login-panel">
<div class="login-card">
<div class="login-mobile-brand"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
<h2>登录管理后台</h2>
<p>使用平台内部员工账号登录</p>
<div class="login-card workbench-panel">
<div class="login-mobile-brand workbench-brand-mark"><img :src="baiyueLogoWhite" alt="柏悦物流" /></div>
<div class="login-card-icon workbench-icon"><LockKeyhole :size="24" aria-hidden="true" /></div>
<h2 class="workbench-title">登录管理后台</h2>
<p class="workbench-note">使用平台内部员工账号登录</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" aria-label="平台员工登录" @submit.prevent="submit">
<el-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" autofocus />
@@ -84,38 +85,43 @@ async function submit() {
登录 <ArrowRight :size="17" />
</el-button>
</el-form>
<div v-if="loginError" class="login-error" role="alert" aria-live="polite"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
<div v-if="loginError" class="login-error workbench-notice workbench-tone" data-tone="rose" role="alert" aria-live="polite"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
</div>
</section>
</div>
</template>
<style scoped>
.login-page { min-width: 0; min-height: 100svh; background: var(--cc-surface); color: var(--cc-text); }
.login-intro { min-width: 0; background: var(--cc-navy); }
.login-intro h1 { max-width: 560px; margin: 16px 0 14px; font-size: 40px; line-height: 1.2; overflow-wrap: anywhere; }
.intro-copy { margin: 0; color: #cbd5e1; font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
.login-panel { min-width: 0; background: var(--cc-surface-muted); }
.login-card { min-width: 0; padding: 32px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); box-shadow: var(--cc-shadow-md); }
.login-card-icon { border-radius: var(--cc-radius-lg); background: var(--cc-primary-soft); }
.login-card h2, .login-card > p { overflow-wrap: anywhere; }
.login-page { display: grid; min-width: 0; min-height: 100svh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: var(--workbench-surface); color: var(--workbench-text); }
.login-intro { display: flex; min-width: 0; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); }
.login-brand { display: flex; width: fit-content; min-height: 64px; align-items: center; padding: 8px 20px; }
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
.login-intro h1 { max-width: 560px; }
.login-intro-copy { max-width: 560px; }
.login-security-note > svg { flex: 0 0 auto; }
.login-panel { display: grid; min-width: 0; place-items: center; padding: 48px; background: var(--workbench-surface-tint); }
.login-card.workbench-panel { width: min(410px, 100%); min-width: 0; padding: 32px; }
.login-card h2 { margin-top: 22px; }
.login-card > p { margin: 8px 0 30px; }
.login-mobile-brand { display: none; }
.login-submit { min-height: 44px; }
.login-error { min-width: 0; padding: 12px 14px; border: 1px solid #fecdca; border-radius: var(--cc-radius-md); background: var(--cc-danger-soft); color: var(--cc-danger); overflow-wrap: anywhere; }
.login-submit { width: 100%; min-height: 44px; }
.login-submit :deep(> span) { display: flex; align-items: center; gap: 8px; }
.login-error.workbench-notice { min-width: 0; margin-top: 14px; }
.login-error > p { margin: 0; }
.login-error :deep(*) { max-width: 100%; overflow-wrap: anywhere; }
.login-card :deep(.el-input__wrapper:focus-within) { box-shadow: 0 0 0 1px var(--cc-primary) inset, 0 0 0 3px var(--cc-primary-soft); }
@media (max-width: 960px) {
.login-page { grid-template-columns: 1fr; }
.login-intro { display: none; }
.login-panel { align-items: start; padding: 24px; }
.login-card { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; min-height: 52px; align-items: center; margin: -28px -28px 28px; padding: 12px 20px; border-radius: var(--cc-radius-lg) var(--cc-radius-lg) 0 0; background: var(--cc-navy); }
.login-card.workbench-panel { width: min(440px, 100%); margin: auto; padding: 28px; }
.login-mobile-brand { display: flex; width: fit-content; min-height: 52px; align-items: center; margin-bottom: 28px; padding: 8px 16px; }
.login-mobile-brand img { display: block; width: 154px; height: 48px; object-fit: contain; object-position: left center; }
}
@media (max-width: 360px) {
.login-panel { padding: 12px; }
.login-card { padding: 20px; }
.login-mobile-brand { margin: -20px -20px 24px; padding-inline: 16px; }
.login-card h2 { font-size: 24px; }
.login-card.workbench-panel { padding: 20px; }
.login-mobile-brand { margin-bottom: 24px; }
}
</style>
+37
View File
@@ -0,0 +1,37 @@
# Admin 货柜模块指引
## 路径基准与读取顺序
- 本文件适用于 `src/modules/container-loading/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;接口、状态、金额或权限变化时再读详细索引命中行。
- 后端只查直接消费方:`firstleg/`(`loading/`、`customs/container/`、`terminaldrayage/`、`oceanbooking/`)、`lastleg-service/`(柜级到仓与尾程)、`file/`、`print/`。纯页面与文案不读后端。
## 页面与入口
| 目标 | 页面/路由 | API 与后端 |
|---|---|---|
| 国内装柜列表、模拟柜、订舱关联 | `pages/ContainersView.vue`、`pages/DomesticContainersView.vue`;`container-loading/domestic` | `api/platformContainerLoading.ts`;`components/ContainerSimulationCreate.vue`、`components/OceanBookingLinkDialog.vue`;`firstleg/loading/` |
| 送柜报关详情、装柜清单页签、报关、出仓 | `pages/DomesticContainerDetailView.vue`、共享 `pages/ContainerDetailView.vue`;`container-loading/domestic/:containerId` | `components/ContainerOverviewPanel.vue`、`components/ContainerCustomsPanel.vue`;`api/platformContainerCustoms.ts`;`firstleg/customs/container/`、`file/` |
| 智能装柜工作台、柜次容量与分配 | `pages/SmartLoadingWorkbenchView.vue`;`container-loading/domestic/:containerId/smart-loading` | `api/platformContainerLoading.ts`;`../unified-orders/workflows/components/SmartLoadingWorkbench.vue`;`firstleg/service/SmartLoadingService.java` |
| 全局智能装柜弹框、入仓码候选与箱数 | `pages/GlobalSmartLoadingView.vue`(内嵌,无独立路由)、`components/SmartLoadingDialog.vue`、`components/SmartLoadingCandidateInbounds.vue` | `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`smartLoadingPlanGroups.ts`;后端 `/inbound-candidates`、`/containers`、`/inbound-assignments` |
| 海外清关提柜工作台、船节点、清关与 DO/PU | `pages/OverseasContainersView.vue`;`container-loading/overseas` | `api/platformContainerLoading.ts`;`firstleg/` 船节点与 `customs/`;`components/ContainerOverviewPanel.vue` |
| 目的港码头拖车、车队与还空 | `pages/OverseasContainerDetailView.vue`(薄包装)、共享 `pages/ContainerDetailView.vue`(实际内容);`container-loading/overseas/:containerId` | `api/terminalDrayages.ts` 由共享详情调用;`firstleg/terminaldrayage/`(`TerminalDrayageService.java`、`DirectDeliveryService.java`) |
| 整柜预计/实际到仓 | 列表与详情“到仓”列 | `api/containerArrivalTimes.ts`;`lastleg-service/` 柜级订单 |
| 装柜照片与证据的展示/读取(上传在 PDA 与 Firstleg) | `components/ContainerOverviewPanel.vue` | 证据读取走 `file/`;生产与上传见 `firstleg/loading/service/CabinetLoadingEvidenceService.java` 与国内 PDA |
| 海外柜费用 | `components/ContainerLastLegFeesPanel.vue` | `api/overseasContainerFees.ts`;`finance/`、`firstleg/` 费用来源 |
`pages/ContainerWorkbenchView.vue` 是工作台外壳,按权限在“国内装柜”“清关提柜”间切换;`pages/ContainersView.vue` 与 `ContainerDetailView.vue` 为共享实现,改两份前先确认是否同源。
## 关键边界
- 列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,写资格只取服务端 `allowedActions`;不用浏览器筛选、本地推导或柜内明细数量代替。
- 创建柜次时自动生成唯一预装计划;订舱关联允许真实集装箱号为空,但**确认预装和创建 PDA 任务前必须补齐**,历史上已建缺号任务可在预装详情首次补录。
- 还空截止、超时天数只展示服务端按权威卸柜完成时间加 72 小时计算的结果,前端不重算、不用拖车状态当阻断门禁。
- 国内“送柜报关”只消费 `/containers/domestic`,海外“清关提柜”只消费 `/containers/overseas`;两端统一用 `containerBookingId`,禁止混用两套列表接口。
- 智能装柜候选状态只作筛选展示,按钮资格以服务端 `canAssign` 为准;计划箱数只消费 `totalPackageCount/remainingPackageCount`,不从候选页或本地规格重算。
- 前端不计算托数、不逐票建计划、不预占托盘;柜级转换必须原样回传服务端 `snapshotVersion/snapshotHash`,冲突后重新预览。
## 验证
- 定向:`pnpm test src/modules/container-loading/<目标测试>`;API 或类型变化同时运行 `pnpm typecheck`。
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`;离线检查不代替真实 HTTP 与人工截图验收。
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
await listContainerArrivalTimes(['9001'])
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
})
it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
}
})
})
@@ -6,6 +6,16 @@ export interface ContainerArrivalTime {
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
version: number
planCount: number
plannedDate?: string | null
isDefault?: boolean
}
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
const count = Number(item.planCount)
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
return { ...item, version: Number(item.version), planCount: count }
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
@@ -20,7 +30,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
queryPath,
{ sourceContainerBookingIds },
)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
return (unwrapApiResponse(response) || []).map(normalizeArrival)
}
export async function updateContainerExpectedArrival(
@@ -33,5 +43,5 @@ export async function updateContainerExpectedArrival(
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
return { ...result, version: Number(result.version || 0) }
return normalizeArrival(result)
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
@@ -15,6 +15,22 @@ const customs = {
describe('platform container customs API', () => {
beforeEach(() => vi.restoreAllMocks())
it('preserves the server batch number when creating and submitting a declaration', async () => {
const batch = { id: '123', customsContainerId: '456', loadingPlanId: '789', batchNo: 'DECL202610200001', sourceVersion: 1, version: 0, results: [] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: batch })
const created = await createDeclarationBatch('789', 3)
expect(created.batchNo).toBe('DECL202610200001')
expect(post.mock.calls[0]?.slice(0, 2)).toEqual([
'/platform/v1/customs/loading-plans/789/declaration-batches', { expectedVersion: 3, remark: null },
])
const submitted = await submitDeclarationBatch(created, ' CUSTOMS-EXTERNAL-1 ')
expect(submitted.batchNo).toBe(created.batchNo)
expect(post.mock.calls[1]?.slice(0, 2)).toEqual([
'/platform/v1/customs/loading-plans/declaration-batches/123/submit',
{ expectedVersion: 0, customsEntryNo: 'CUSTOMS-EXTERNAL-1', remark: null },
])
})
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
vi.useFakeTimers()
try {
@@ -178,13 +178,13 @@ export async function getDeclarationBatches(loadingPlanId: string) {
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
}
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
@@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
downloadSource: vi.fn(),
downloadReceipt: vi.fn(),
downloadFinal: vi.fn(),
recordResult: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
@@ -27,6 +29,7 @@ vi.mock('../api/platformContainerCustoms', () => ({
submitContainerCustomsToBroker: vi.fn(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
recordClearanceResult: api.recordResult,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
@@ -77,6 +80,51 @@ describe('ContainerCustomsPanel', () => {
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
const batch: DeclarationBatch = {
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
}
api.getDeclarationBatches.mockResolvedValue([batch])
api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: {
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
} }).setupState
state.clearanceResultType = 'OTHER'
await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
expect(dialog.props('lockWhileSubmitting')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.recordResult).not.toHaveBeenCalled()
state.clearanceRemark = ' 已核实海关通知 '
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
expect(dialog.props('modelValue')).toBe(false)
wrapper.unmount()
})
it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
const wrapper = await render('READY_TO_START')
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
expect(api.start).not.toHaveBeenCalled()
confirm.mockRestore()
wrapper.unmount()
})
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
const reasons: Array<[ClearanceResultType, string]> = [
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
@@ -116,8 +164,7 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关行回执')
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -7,6 +8,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import {
completeAllBuyContainerCustoms,
createDeclarationBatch,
@@ -64,7 +67,6 @@ function clearanceResultLabel(value: ClearanceResultType) {
}
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
@@ -113,13 +115,18 @@ async function load() {
finally { loading.value = false }
}
function customsDialogTitle(action: string, batch?: DeclarationBatch | null) {
const code = batch?.batchNo?.trim()
return code ? `${action} · ${code}` : containerDialogTitle(action, customs.value)
}
function declarationStatusLabel(value: DeclarationBatch['status']) {
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
}
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
@@ -128,7 +135,7 @@ async function createFormalDeclaration() {
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
const answer = await ElMessageBox.prompt('请输入报关单号', customsDialogTitle('提交正式申报', batch), { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
@@ -163,7 +170,7 @@ async function recordFormalResult() {
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
const answer = await ElMessageBox.prompt('请输入处理说明', customsDialogTitle('处理海关结果', batch), { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
@@ -176,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
@@ -242,7 +249,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
@@ -251,7 +258,7 @@ async function submitToBroker() {
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
@@ -260,7 +267,7 @@ async function sendToBroker() {
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', customsDialogTitle('完成全买单柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
catch { return }
actionLoading.value = true; actionError.value = null
try {
@@ -288,10 +295,10 @@ onMounted(load)
</script>
<template>
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
<section class="container-customs surface-panel admin-density admin-workbench" aria-label="整柜报关">
<OperationErrorToast :details="actionError" />
<header class="customs-header">
<div><h2>整柜报关</h2><p>资料、回执与完税文件按柜管理。</p></div>
<div><h2>整柜报关</h2></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
@@ -307,20 +314,17 @@ onMounted(load)
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<dl class="density-fields customs-summary" aria-label="报关概况">
<div><dt>实际箱数</dt><dd>{{ customs.actualBoxCount }}</dd></div><div><dt>退税总票</dt><dd>{{ customs.taxRefundCount }}</dd></div><div><dt>已覆盖</dt><dd>{{ customs.coveredTaxRefundCount }}</dd></div><div><dt>缺失</dt><dd :class="{ 'coverage-missing': missingCoverageCount }">{{ missingCoverageCount }}</dd></div>
<template v-if="brokerSubmission">
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
</template>
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
</dl>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
@@ -328,10 +332,10 @@ onMounted(load)
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
</tr>
</tbody></table>
</tbody></table></div>
</div>
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>查看申报批次及海关处理结果。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
@@ -346,10 +350,10 @@ onMounted(load)
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
<el-dialog v-model="clearanceDialogVisible" class="admin-density-dialog" title="标记海关异常/结果" width="460px" :close-on-click-modal="false">
<el-form label-position="top" class="clearance-result-form">
<AppDialog v-model="clearanceDialogVisible" class="admin-density-dialog admin-workbench is-dialog" :title="customsDialogTitle('标记海关异常/结果', clearanceBatch)" width="460px" :close-on-click-modal="false" :lock-while-submitting="false" :confirm-loading="actionLoading" confirm-text="确认标记" @confirm="recordFormalResult">
<el-form label-position="top" class="density-form density-form--flush-end">
<el-form-item label="选择理由" required>
<el-select v-model="clearanceResultType" class="clearance-result-select" placeholder="请选择理由">
<el-select v-model="clearanceResultType" placeholder="请选择理由">
<el-option v-for="option in clearanceResultOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
@@ -357,11 +361,10 @@ onMounted(load)
<el-input v-model="clearanceRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="clearanceResultType === 'OTHER' ? '请填写具体情况' : '可填写短信内容或人工处理说明(可选)'" />
</el-form-item>
</el-form>
<template #footer><el-button @click="clearanceDialogVisible = false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="recordFormalResult">确认标记</el-button></template>
</el-dialog>
</AppDialog>
</section>
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
</style>
@@ -1,25 +1,26 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import ElementPlus, { ElFormItem, ElSelect } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [{ id: '21', supplierName: '供应商甲', category: 'TERMINAL_DRAYAGE' }] }))
vi.mock('../api/overseasContainerFees', () => ({
listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
}))
async function setup(readonly = false) {
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
async function setup(readonly = false, realDialog = false, containerNo: string | null = 'CONT-99') {
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', cabinetNo: 'CAB-99', containerNo, readonly }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : {
AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '<section v-if="modelValue"><h3>{{ title }}</h3><slot /><button :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>' },
} } })
await flushPromises()
return wrapper
}
type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
type FormState = { form: { customerId: string; settlementMode: string; direction: string; amount: number | null; businessDate: string; supplierId: string; paidAt: string; paymentReferenceNo: string }; dialogOpen: boolean }
async function upload(wrapper: Awaited<ReturnType<typeof setup>>, name: string) {
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
@@ -37,6 +38,60 @@ describe('尾程付款凭证', () => {
mocks.download.mockResolvedValue(new Blob(['image']))
})
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
const wrapper = await setup(false, true)
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
expect(dialog.props('title')).toBe('新增尾程费用 · CAB-99')
const labels = () => dialog.findAllComponents(ElFormItem).map(item => item.props('label'))
expect(labels()).toHaveLength(7)
state.form.settlementMode = 'DIRECT_PAID'
state.form.amount = 120.25
state.form.supplierId = '21'
state.form.businessDate = '2026-10-19'
state.form.paidAt = '2026-10-19T09:00:00'
state.form.paymentReferenceNo = ' PAY-001 '
await flushPromises()
expect(labels()).toHaveLength(11)
await dialog.get('[aria-label="展开全屏"]').trigger('click')
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
expect(state.form.amount).toBe(120.25)
await dialog.get('[aria-label="退出全屏"]').trigger('click')
expect(state.form.paymentReferenceNo).toBe(' PAY-001 ')
dialog.vm.$emit('confirm')
await flushPromises()
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
customerId: '11', containerBookingId: '99', containerNo: 'CONT-99', businessNo: 'CONT-99',
amount: 120.25, supplierId: '21', settlementMode: 'DIRECT_PAID',
paidAt: '2026-10-19T09:00:00', paymentReferenceNo: 'PAY-001', currency: 'CNY',
}), expect.stringContaining('container-fee-create-'))
wrapper.unmount()
})
it('调整的所属客户显示只读名称,应收费用隐藏供应商和付款字段', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', sourceVersion: 7, customerId: '11', customerName: '客户甲', feeItemCode: 'PORT_FEE', amount: 10, businessDate: '2026-10-19', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', paymentVoucherFileIds: [] }])
const wrapper = await setup(false, true)
await wrapper.findAll('button').find(button => button.text() === '调整')!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整尾程费用'))!
const fields = dialog.findAllComponents(ElFormItem)
expect(fields.map(item => item.props('label'))).toEqual(['客户', '费用项目', '费用方向', '业务日期', '金额(CNY)'])
const customer = fields[0]!
expect(customer.text()).toContain('客户甲')
expect(customer.findComponent(ElSelect).exists()).toBe(false)
wrapper.unmount()
})
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
const wrapper = await setup(false, true, null)
expect(wrapper.findAll('button').find(button => button.text() === '新增费用')!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
expect(mocks.create).not.toHaveBeenCalled()
wrapper.unmount()
})
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
const wrapper = await setup()
@@ -48,7 +103,7 @@ describe('尾程付款凭证', () => {
it('显示上传文件名,允许再次上传替换和移除', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
@@ -66,7 +121,7 @@ describe('尾程付款凭证', () => {
it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
@@ -1,5 +1,7 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getFinancePaymentVoucher, downloadFinancePaymentVoucher } from '@/modules/finance'
@@ -7,11 +9,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; cabinetNo?: string | null; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const access = useAccessPreviewStore()
const rows = ref<OverseasContainerFeeSource[]>([])
const customers = ref<OverseasContainerCustomer[]>([])
@@ -146,25 +149,25 @@ async function uploadVoucher(event: Event) {
}
}
async function submit(row: OverseasContainerFeeSource) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, containerDialogTitle('提交费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
let reason: string | null = null
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', containerDialogTitle('驳回费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, containerDialogTitle('审核费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
}
async function voidFee(row: OverseasContainerFeeSource) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
const result = await ElMessageBox.prompt('请输入作废原因', containerDialogTitle('作废费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
}
onMounted(load)
</script>
<template>
<div class="last-leg-fees admin-density">
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<div class="last-leg-fees admin-density admin-workbench">
<header class="last-leg-fees__header"><div><h2>{{ title }}<InlineHelp title="费用统计说明"><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></InlineHelp></h2></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
@@ -182,13 +185,24 @@ onMounted(load)
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section">
<el-form class="density-form" label-position="left">
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE'"><td :colspan="form.settlementMode === 'DIRECT_PAID' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item></td><td v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label-position="left" label-width="110px" label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" /></el-form-item></td></tr>
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td :colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></td></tr>
</tbody></table></div>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<AppDialog class="admin-density-dialog last-leg-voucher-dialog admin-workbench is-dialog" v-model="previewOpen" :title="containerDialogTitle('付款凭证', props)" :fullscreenable="Boolean(previewUrl) && previewIsImage" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<p v-if="previewLoading">正在读取付款凭证…</p>
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
<div v-else-if="previewUrl" class="density-dialog-section voucher-preview" data-tone="source"><p class="density-dialog-note">{{ previewName }}</p><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
</AppDialog>
</div>
</template>
@@ -201,10 +215,13 @@ onMounted(load)
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
.voucher-preview { display: grid; gap: var(--admin-space-5); }
.voucher-preview { display: grid; gap: var(--admin-space-5); min-width: 0; overflow-wrap: anywhere; }
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
.row-actions { white-space: nowrap; }
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
.fee-editor-table { table-layout: fixed; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; } }
</style>
<style>
.last-leg-voucher-dialog.is-fullscreen .voucher-preview img { max-height: 80vh; }
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
@@ -120,15 +121,15 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</script>
<template>
<div class="overview-panel admin-density">
<div class="overview-panel admin-density admin-workbench">
<section v-if="visibleSections.has('exception')" class="overview-section exception-management" aria-label="异常管理">
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
<div class="exception-history"><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><template v-else><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><ol><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></template></div>
</section>
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap">
<table class="document-table density-table">
<div class="workbench-evidence"><table class="document-table density-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
@@ -145,14 +146,14 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody>
</table>
</table></div>
</div>
</section>
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
@@ -165,13 +166,13 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
.overview-section--documents{padding:0;border:0}
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.overview-section h3 { margin: 0; font-size: var(--admin-font-section); }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{color:var(--cc-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
.overview-section header > span, .overview-section small { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.overview-section p { margin: 5px 0; color: var(--workbench-muted); font-size: var(--admin-font-body); }
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--workbench-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--workbench-border)}.exception-history li time,.exception-history li span{color:var(--workbench-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--workbench-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--workbench-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--workbench-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--workbench-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.overview-panel .pda-summary { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--admin-space-4); }
.pda-progress dd { width: 100%; }
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--workbench-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--workbench-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
</style>
@@ -0,0 +1,68 @@
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import type { PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
import { createContainerSimulation } from '../api/platformContainerLoading'
import AppDialog from '@/shared/components/AppDialog.vue'
import ContainerSimulationCreate from './ContainerSimulationCreate.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { h } from 'vue'
// Node's supported CJS export keeps the real form validator's constructor intact.
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ createContainerSimulation: vi.fn() }))
const warehouse: PlatformWarehouseSummaryResource = { id: '8', tenantId: 'platform', warehouseCode: 'CN-01', internalCode: null, warehouseName: '测试国内仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
enableAutoUnmount(afterEach)
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }]) })
afterEach(() => { document.body.innerHTML = '' })
describe('ContainerSimulationCreate opted-in form', () => {
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
const host = mount(DataTable, {
props: { data: [], columns: [] },
slots: { 'toolbar-actions': () => h(ContainerSimulationCreate, { dense, loadWarehouseOptions: async () => [warehouse] }) },
global: { plugins: [ElementPlus] },
attachTo: document.body,
})
const wrapper = host.findComponent(ContainerSimulationCreate)
expect(wrapper.get('button').element.closest('.data-table__toolbar-actions')).not.toBeNull()
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
const dialogRoot = document.body.querySelector<HTMLElement>('.simulation-create-dialog')!
expect(dialogRoot).not.toBeNull()
expect(dialogRoot.closest('.data-table__toolbar')).toBeNull()
expect(dialogRoot.querySelector('.el-dialog__footer button')?.closest('.data-table__toolbar')).toBeNull()
expect(dialog.props('width')).toBe('min(800px, calc(100vw - 32px))')
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).not.toHaveBeenCalled()
await vi.waitFor(() => expect(dialogRoot.querySelectorAll('.el-form-item__error').length).toBeGreaterThan(0))
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
expect(fields).toHaveLength(3)
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
await flushPromises()
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
await flushPromises()
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
expect(wrapper.emitted('created')).toHaveLength(1)
expect(dialog.props('modelValue')).toBe(false)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialogRoot.closest('.data-table__toolbar')).toBeNull()
expect(dialog.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
const cancel = Array.from(dialogRoot.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '取消')!
cancel.click()
await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
expect(host.find('.data-table__toolbar-actions').exists()).toBe(true)
expect(createContainerSimulation).toHaveBeenCalledTimes(1)
})
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Container, Plus } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
@@ -9,7 +10,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
import { createContainerSimulation } from '../api/platformContainerLoading'
import type { ContainerSimulationCreateRequest } from '../types'
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
@@ -95,19 +96,55 @@ async function saveSimulation() {
<template>
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog
v-model="simulationOpen"
class="simulation-create-dialog"
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense, 'admin-workbench': dense, 'is-dialog': dense }"
title="新增模拟柜"
append-to-body
width="min(800px, calc(100vw - 32px))"
confirm-text="创建模拟柜"
cancel-text="取消"
:confirm-loading="simulationSaving"
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
@confirm="saveSimulation"
>
<template #header>
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
</template>
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<div class="simulation-fields">
<el-form-item label="装柜仓库" prop="warehouseId">
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
<el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" />
</el-select>
</el-form-item>
<el-form-item label="物流渠道" prop="channelGroupCode">
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
</el-select>
</el-form-item>
<el-form-item label="柜型" prop="containerType" class="simulation-type">
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
</el-select>
</el-form-item>
</div>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
.simulation-fields > * { min-width: 0; }
.simulation-form :deep(.el-select) { width: 100%; }
.simulation-type { grid-column: 1 / -1; }
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
@media (max-width: 600px) {
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.simulation-type :deep(.el-select) { max-width: none; }
}
</style>
@@ -5,6 +5,7 @@ import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import type { ContainerBookingResource } from '../types'
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
@@ -35,6 +36,10 @@ describe('OceanBookingLinkDialog', () => {
it('loads matching released-booking requirements when opened', async () => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findComponent(AppDialog).props('title')).toBe('关联已放舱订舱 · CAB-1')
const identity = wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('label') === '柜次号')!
expect(identity.text()).toContain('CAB-1')
expect(identity.find('input').exists()).toBe(false)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
wrapper.unmount()
@@ -49,6 +54,9 @@ describe('OceanBookingLinkDialog', () => {
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
vm.candidateKey = '101:202'
vm.containerNo = input
await wrapper.findComponent(AppDialog).find('button[aria-label="展开全屏"]').trigger('click')
expect(vm.containerNo).toBe(input)
expect(linkContainerOceanBooking).not.toHaveBeenCalled()
await vm.save()
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
@@ -8,6 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import { containerDialogTitle } from '../containerDialogTitle'
import type { ContainerBookingResource } from '../types'
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
@@ -98,18 +100,16 @@ defineExpose({ candidates, candidateKey, containerNo, save })
</script>
<template>
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
<el-form class="density-form" label-position="top">
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
</el-form>
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<section class="density-dialog-section">
<el-form class="density-form" label-position="left" label-width="160px">
<div class="workbench-evidence"><table class="density-table" aria-label="关联订舱资料"><tbody>
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
</tbody></table></div>
</el-form>
</section>
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
</style>
@@ -22,6 +22,7 @@ describe('SmartLoadingDialog', () => {
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(document.querySelector('.smart-loading-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
@@ -45,7 +46,9 @@ describe('SmartLoadingDialog', () => {
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
expect(source).toContain('height: 94vh; max-height: 94vh;')
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
expect(source).toContain('.smart-loading-dialog.app-dialog.admin-density-dialog > .el-dialog__body')
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
expect(source).toContain('min-height: 0; flex: 1; padding: var(--admin-space-5); box-sizing: border-box; overflow: hidden;')
expect(source).toContain('width="min(1840px, calc(100vw - 48px))"')
})
})
@@ -1,4 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
@@ -8,9 +10,9 @@ const open = defineModel<boolean>({ default: false })
<template>
<AppDialog
v-model="open"
class="smart-loading-dialog"
class="smart-loading-dialog admin-density-dialog admin-workbench is-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
width="min(1840px, calc(100vw - 48px))"
fullscreenable
default-fullscreen
cancel-text="关闭"
@@ -22,9 +24,9 @@ const open = defineModel<boolean>({ default: false })
</template>
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1840px, calc(100vw - 48px)); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog.app-dialog.admin-density-dialog > .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog > .el-dialog__body > .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; padding: var(--admin-space-5); box-sizing: border-box; overflow: hidden; }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { containerDialogTitle } from './containerDialogTitle'
describe('containerDialogTitle business identity', () => {
it('uses the stable cabinet number when both business identifiers exist', () => {
expect(containerDialogTitle('编辑货柜资料', { cabinetNo: ' CB-001 ', containerNo: 'MSCU1234567' })).toBe('编辑货柜资料 · CB-001')
})
it('uses a real container number if the cabinet number is missing', () => {
expect(containerDialogTitle('确认还柜', { cabinetNo: ' ', containerNo: ' MSCU1234567 ' })).toBe('确认还柜 · MSCU1234567')
})
it('never substitutes an internal ID for a missing business identifier', () => {
const container = { id: 'private-container-id', cabinetNo: null, containerNo: ' ' }
expect(containerDialogTitle('直送卸货登记', container)).toBe('直送卸货登记')
expect(containerDialogTitle('直送卸货登记', null)).toBe('直送卸货登记')
})
})
@@ -0,0 +1,6 @@
type ContainerIdentity = { cabinetNo?: string | null; containerNo?: string | null }
export function containerDialogTitle(action: string, container?: ContainerIdentity | null) {
const code = container?.cabinetNo?.trim() || container?.containerNo?.trim()
return code ? `${action} · ${code}` : action
}
+2
View File
@@ -1,5 +1,7 @@
export * from './api/platformContainerLoading'
export { listContainerArrivalTimes, type ContainerArrivalTime } from './api/containerArrivalTimes'
export * from './types'
export { projectWarehouseArrival } from './warehouseArrival'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
}
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
})
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
@@ -93,14 +101,16 @@ describe('ContainerDetailView empty return', () => {
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
}
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
await flushPromises(); await dialog.find('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent(ElDatePicker).props('modelValue')).toBe('2026-10-16T01:00:00')
dialog.vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
expect(dialog.text()).not.toContain('9007199254740999')
@@ -114,24 +124,46 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
expect(dialog.findAll('input, textarea')).toHaveLength(0)
expect(dialog.text()).toContain('POD-001')
expect(dialog.text()).toContain('America/Los_Angeles')
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
})
it('does not mislabel a cabinet number as a real container number or an unnamed fleet as unassigned', async () => {
routeState.query = { action: 'pickup' }
const withoutNumber = { ...(await getContainer('9001')), containerNo: '' }
const unnamedFleet = { ...(await getTerminalDrayage('9001')), containerNo: '', fleetSupplierId: 'private-supplier-id', fleetSupplierName: '' }
vi.mocked(getContainer).mockResolvedValue(withoutNumber)
vi.mocked(getTerminalDrayage).mockResolvedValue(unnamedFleet)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
const fields = dialog.findAllComponents(ElFormItem)
const number = fields.find(field => field.props('label') === '真实集装箱号')!
expect(number.text()).toBe('真实集装箱号—')
const fleet = fields.find(field => field.props('label') === '当前车队')!
expect(fleet.text()).toBe('当前车队—')
expect(fleet.text()).not.toContain('private-supplier-id')
expect(fleet.text()).not.toContain('尚未安排')
})
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
routeState.query = { action: 'pickup' }
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
const fleetButton = dialog.findAll('button').find(button => button.text() === '保存码头车队')!
expect(fleetButton.element.closest('.el-dialog__footer')).not.toBeNull()
await fleetButton.trigger('click'); await flushPromises()
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
@@ -143,7 +175,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await mountDetail()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
@@ -155,7 +187,7 @@ describe('ContainerDetailView empty return', () => {
routeState.query = { action: 'pickup' }
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
@@ -175,7 +207,7 @@ describe('ContainerDetailView empty return', () => {
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.text()).toContain('整柜已确认离仓;')
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
@@ -211,7 +243,7 @@ describe('ContainerDetailView empty return', () => {
expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises()
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('确认还柜'))!
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
confirm.click()
@@ -53,9 +53,9 @@ describe('ContainerDetailView customs integration', () => {
})
it('keeps the loaded overview mounted while switching fee tabs', () => {
expect(source).toContain("v-show=\"activeTab === 'overview'\" class=\"detail-section surface-panel\" aria-labelledby=\"container-voyage-heading\"")
expect(source).toContain("v-show=\"activeTab === 'overview'\" class=\"detail-section workbench-panel is-compact\" aria-labelledby=\"container-voyage-heading\"")
expect(source).toContain("v-if=\"isOverseas\" v-show=\"activeTab === 'overview'\"")
expect(source).not.toContain("v-if=\"activeTab === 'overview'\" class=\"detail-section surface-panel\"")
expect(source).not.toContain("v-if=\"activeTab === 'overview'\" class=\"detail-section workbench-panel is-compact\"")
})
it('exposes empty return directly in overseas detail only when permission and server action both allow it', () => {
@@ -75,7 +75,10 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
const firstLegFees = source.match(/<ContainerFeesPanel\b[^>]*\/>/)?.[0] || ''
for (const attribute of ['title="国内段费用(头程)"', ':plan-id="overview.loadingPlanId"', ':container-booking-id="detail.id"', ':cabinet-no="detail.cabinetNo"', ':container-no="detail.containerNo"', ':customers="feeCustomers"', ':business-references="feeBusinessReferences"']) {
expect(firstLegFees).toContain(attribute)
}
expect(source).not.toContain(':readonly="isOverseas"')
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
expect(source).toContain('title="目的港及海外段费用(尾程)"')
@@ -1,5 +1,7 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { computed, onMounted, reactive, ref, watch } from 'vue'
@@ -25,6 +27,7 @@ import {
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -43,6 +46,7 @@ import {
} from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
@@ -88,6 +92,12 @@ async function rememberWorkbenchTab(tabName: string | number) {
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const arrivalTime = ref<ContainerArrivalTime | null>(null)
const expectedArrivalText = computed(() => {
const arrival = arrivalTime.value
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
: projected.date ? `${projected.date}${projected.isDefault ? '(默认推算)' : ''}` : '—'
})
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
@@ -172,7 +182,7 @@ async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
async function leaveTerminal() {
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
onBeforeRouteLeave(leaveTerminal)
@@ -186,7 +196,7 @@ const directPending = ref<{ containerId: string; requirementKey: string; body: D
async function leaveDirect() {
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
onBeforeRouteLeave(leaveDirect)
onBeforeRouteUpdate(async (to, from) => {
@@ -287,7 +297,7 @@ const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEm
async function leaveEmptyReturn() {
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
}
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
onBeforeRouteLeave(leaveEmptyReturn)
@@ -310,9 +320,6 @@ const overseasStageText = computed(() => {
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
? '核对作业证据、报关资料与装柜货物。'
: '核对预装货物与 PDA 作业进度。')
const overseasDestinationText = computed(() => {
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
.filter(Boolean).join(' / ')
@@ -486,7 +493,7 @@ async function confirmDeparture() {
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
@@ -664,8 +671,8 @@ onMounted(async () => {
</script>
<template>
<AdminPage class="container-detail-page admin-density">
<section class="container-overview surface-panel">
<AdminPage spacing="compact" class="container-detail-page admin-density admin-workbench">
<section class="container-overview workbench-panel is-flush">
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />{{ appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表' }}</el-button><div><h1>{{ title }}</h1>
<!-- <p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p> -->
</div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
@@ -674,44 +681,43 @@ onMounted(async () => {
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<el-tabs v-model="activeTab" class="workbench-tabs density-element-tabs" @tab-change="rememberWorkbenchTab">
<el-tabs v-model="activeTab" class="density-element-tabs" @tab-change="rememberWorkbenchTab">
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📋</span><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📦</span><span>装柜清单</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>国内段费用(头程)</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
</el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-information-heading">
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ containerChannelLabel(detail) }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" :delivery-order-blocked-reason="deliveryOrderBlockedReason" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
</section>
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
@@ -722,70 +728,73 @@ onMounted(async () => {
</div>
</section>
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div></header>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section workbench-panel is-compact" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
</section>
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section workbench-panel is-compact" aria-label="尾程计费">
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
</section>
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
<p v-if="directLoading">正在读取实际装柜来源…</p>
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><div class="workbench-evidence"><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></div></section>
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
<section v-if="directSelected" class="density-dialog-section"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
</el-form>
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
</template>
</AppDialog>
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
.container-workflow-table { table-layout: fixed; }
.direct-remark { white-space: pre-wrap; }
.loading-list-heading { margin-bottom: var(--admin-space-5); }
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.loading-list-heading ~ .container-customs, .loading-list-heading ~ .loading-detail-content { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.loading-list-heading ~ .container-customs, .loading-list-heading ~ .loading-detail-content { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--workbench-border); }
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:var(--admin-space-3); white-space:nowrap; }
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--cc-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--cc-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section{display:grid;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-5) var(--admin-space-5)}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:var(--admin-space-4);border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--workbench-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--workbench-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section {display:grid;min-width:0;gap:var(--admin-space-5);}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--workbench-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:var(--admin-space-4);border-left:1px solid var(--workbench-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--workbench-border);border-radius:var(--admin-radius);background:var(--workbench-surface-soft)}.gate-row__actual,.gate-row small{color:var(--workbench-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); }
.detail-section__heading h2 { margin:0; font-size:var(--admin-font-section); }
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-6);padding:9px var(--admin-space-5);border-top:1px solid var(--cc-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:var(--admin-space-4)}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--cc-muted);font-size:var(--admin-font-caption)}.overseas-stage-strip strong{color:var(--cc-primary);font-size:var(--admin-font-section)}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}.container-overview__header h1 { overflow-wrap:anywhere; }.workbench-tab-label { gap:var(--admin-space-2); }
.detail-section__heading { flex-wrap:wrap; }
.detail-section[aria-labelledby="container-information-heading"] .density-fields { grid-template-columns:repeat(3,minmax(0,1fr)); }
.detail-section[aria-labelledby="container-voyage-heading"] .density-fields { grid-template-columns:minmax(0,1fr) minmax(0,1.6fr) repeat(2,minmax(0,1fr)); }
@media(max-width:720px) {
.detail-section[aria-labelledby] .detail-fields .density-fields { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-6);padding:9px var(--admin-space-5);border-top:1px solid var(--workbench-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:var(--admin-space-4)}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--workbench-muted);font-size:var(--admin-font-caption)}.overseas-stage-strip strong{color:var(--workbench-primary);font-size:var(--admin-font-section)}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}.container-overview__header h1 { overflow-wrap:anywhere; }.workbench-tab-label { gap:var(--admin-space-2); }
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,7 +15,7 @@ const current = computed(() => props.stage === 'DOMESTIC' && !canDomestic.value
</script>
<template>
<div class="admin-density">
<div class="admin-density admin-workbench">
<nav class="density-status-tabs" aria-label="货柜业务阶段">
<el-button v-if="canDomestic" :class="{ 'is-active': current === 'DOMESTIC' }" :aria-current="current === 'DOMESTIC' ? 'page' : undefined" @click="router.push({ name: 'domestic-container-workbench' })">📦 国内装柜</el-button>
<el-button v-if="canOverseas" :class="{ 'is-active': current === 'OVERSEAS' }" :aria-current="current === 'OVERSEAS' ? 'page' : undefined" @click="router.push({ name: 'overseas-container-workbench' })">🚢 海外清关提柜</el-button>
@@ -22,7 +22,8 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() }))
@@ -73,8 +74,8 @@ describe('ContainersView', () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
@@ -133,7 +134,7 @@ describe('ContainersView', () => {
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
await returnDialog.find('textarea').setValue('还柜凭证已核对')
const callsBefore = vi.mocked(listContainers).mock.calls.length
@@ -176,7 +177,7 @@ describe('ContainersView', () => {
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
@@ -200,7 +201,7 @@ describe('ContainersView', () => {
const open = async () => {
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
await flushPromises()
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
}
const first = await open()
first.vm.$emit('update:modelValue', false)
@@ -270,7 +271,19 @@ describe('ContainersView', () => {
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
expect(editorFields).toHaveLength(17)
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
vessel.vm.$emit('update:modelValue', '编辑中的船名')
await flushPromises()
await editor.find('button[aria-label="展开全屏"]').trigger('click')
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(vessel.props('modelValue')).toBe('编辑中的船名')
expect(updateContainer).not.toHaveBeenCalled()
editor.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
@@ -291,7 +304,7 @@ describe('ContainersView', () => {
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('装柜仓库加载失败')
expect(document.body.querySelector('.simulation-create-dialog')?.textContent).toContain('装柜仓库加载失败')
})
it('uses stable view labels when cabinetNo is missing', async () => {
@@ -416,10 +429,10 @@ describe('ContainersView', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog admin-workbench is-dialog"')
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
expect(source).toContain('admin-density-dialog')
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
@@ -466,7 +479,9 @@ describe('ContainersView', () => {
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain(':stripe="false"')
expect(source).not.toContain(':stripe="false"')
})
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
@@ -569,6 +584,30 @@ describe('ContainersView', () => {
})
})
it('shows the calendar default from actual port arrival without submitting an invented time', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
})) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
expect(picker).toBeDefined()
expect(picker.props('modelValue')).toBe('')
expect(updateContainerExpectedArrival).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a frozen missing arrival undated and prevents editing it', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')!
expect(picker.props('disabled')).toBe(true)
wrapper.unmount()
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
@@ -588,7 +627,7 @@ describe('ContainersView', () => {
expect(link).toBeDefined()
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
} finally {
wrapper.unmount()
@@ -617,8 +656,8 @@ describe('ContainersView', () => {
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(simulationSource).not.toContain('创建后自动生成柜次号')
expect(source).toContain("{{ form.cabinetNo || '—' }}")
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
expect(source).not.toContain('openCreate')
@@ -725,7 +764,7 @@ describe('ContainersView', () => {
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRoute, useRouter } from 'vue-router'
@@ -21,6 +22,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
import { containerDialogTitle } from '../containerDialogTitle'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
@@ -38,6 +40,7 @@ import {
type TerminalDrayageView,
} from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -147,7 +150,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
retryingEmail.value = mail.id
try {
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', containerDialogTitle('重试邮件', emailTarget.value), { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await retryTerminalDrayageEmail(target.id, mail.id)
ElMessage.success('邮件已重新加入发送队列')
await openEmailHistory(target)
@@ -197,7 +200,7 @@ const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:termi
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const editorTitle = computed(() => form.id ? containerDialogTitle('编辑货柜资料', form) : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
interface PortOption { value: string; code: string | null; name: string | null }
@@ -283,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function warehouseArrivalProjection(source: object) {
const row = source as ContainerWorkbenchRow
const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return projected
}
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
@@ -575,7 +584,7 @@ async function saveDrayageAction() {
}
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
const current = arrivalTimes.value[row.id]
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
arrivalSavingIds.value.push(row.id)
try {
const updated = await updateContainerExpectedArrival(row.id, {
@@ -729,19 +738,20 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</script>
<template>
<AdminPage class="admin-density" as="div">
<AdminPage class="admin-density admin-workbench" as="div">
<section class="container-page">
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<section id="container-workbench-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<div class="container-scope-tabs density-status-tabs" role="tablist" aria-label="货柜完结状态">
<section id="container-workbench-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start>
<div class="container-scope-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
</div>
</template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div>
</template>
@@ -753,11 +763,11 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="warehouseArrivalProjection(row as ContainerWorkbenchRow).date ? `${warehouseArrivalProjection(row as ContainerWorkbenchRow).date}${warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '(默认)' : ''}` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
@@ -769,65 +779,58 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
</DataTable></section>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
</DataTable></div></section>
</ListPageLayout>
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog admin-workbench is-dialog" :title="editorTitle" width="min(1080px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
<p class="density-dialog-note">货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</p>
<section v-if="!warehouseError" class="density-dialog-section" data-tone="work">
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
<div class="workbench-evidence"><table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
<tr><td><el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item></td><td colspan="2"><el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item></td></tr>
</tbody></table></div>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="drayageActionOpen" :title="containerDialogTitle(drayageActionText(drayageAction), drayageTarget)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="emailHistoryOpen" :title="containerDialogTitle('车队邮件记录', emailTarget)" fullscreenable width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p class="density-dialog-note">集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<section v-else class="density-dialog-section" data-tone="source"><div class="workbench-table"><DataTable :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
</DataTable>
</DataTable></div></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="drayageHistoryOpen" :title="containerDialogTitle('码头拖车记录', drayageTarget)" fullscreenable width="min(980px, calc(100vw - 28px))" :show-footer="false">
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
<div v-else class="density-dialog-sections"><section v-if="drayageTarget && drayageFor(drayageTarget.id)" class="density-dialog-section" data-tone="summary"><el-descriptions :column="4" border class="density-descriptions"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions></section><section class="density-dialog-section" data-tone="source"><div class="workbench-table"><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></div></section></div>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--workbench-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
</style>
@@ -1,12 +1,15 @@
import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox } from 'element-plus'
import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -25,6 +28,8 @@ import {
removeSmartLoadingPallet,
} from '../api/platformContainerLoading'
enableAutoUnmount(afterEach)
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
vi.mock('vue-router', () => ({
@@ -34,7 +39,8 @@ vi.mock('vue-router', () => ({
vi.mock('@/core/access/accessPreview', () => ({
useAccessPreviewStore: () => ({ can: () => true }),
}))
vi.mock('@/modules/iam', () => ({
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(),
listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }),
}))
vi.mock('../api/platformContainerLoading', () => ({
@@ -117,7 +123,93 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
))))
}
function assignmentDialog() {
const root = document.body.querySelector<HTMLElement>('.smart-loading-assignment-dialog')
if (!root) throw new Error('assignment dialog is missing')
return new DOMWrapper(root)
}
describe('GlobalSmartLoadingView', () => {
it('mounts assignment outside the parent workbench and keeps fullscreen and reopen states independent', async () => {
const wrapper = mount(SmartLoadingDialog, { attachTo: document.body, props: { modelValue: true }, global: { plugins: [ElementPlus] } })
try {
await flushPromises()
const workbench = wrapper.getComponent(GlobalSmartLoadingView)
const openAssignment = async () => {
await workbench.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
await flushPromises()
}
await openAssignment()
const popup = workbench.getComponent(AppDialog)
const outer = document.querySelector('.smart-loading-dialog')!
const child = assignmentDialog()
expect(document.body.contains(child.element)).toBe(true)
expect(outer.contains(child.element)).toBe(false)
expect(child.element.closest('.global-loading-page')).toBeNull()
expect(child.classes()).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
popup.getComponent(ElInputNumber).vm.$emit('update:modelValue', 4)
await child.get('[aria-label="展开全屏"]').trigger('click')
expect(child.classes()).toContain('is-fullscreen')
expect(outer.classList.contains('is-fullscreen')).toBe(true)
await child.findAll('button').find(button => button.text() === '取消')!.trigger('click')
await flushPromises()
expect(popup.props('modelValue')).toBe(false)
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
expect(workbench.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
await openAssignment()
expect(popup.props('modelValue')).toBe(true)
expect(assignmentDialog().classes()).not.toContain('is-fullscreen')
expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(candidate.availablePackageCount)
expect(outer.classList.contains('is-fullscreen')).toBe(true)
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
} finally {
wrapper.unmount()
}
})
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
if (unitType === 'SEALED_PALLET') {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType, palletId: '801', palletNo: 'DP-TEST-1', indivisible: true,
groupMembers: [
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户甲', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户甲', availablePackageCount: 9, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}]))
}
vi.mocked(assignSmartLoadingItem).mockResolvedValue(plan)
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
await flushPromises()
const popup = wrapper.getComponent(AppDialog)
expect(assignmentDialog().get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
if (unitType === 'PACKAGE') {
const input = popup.getComponent(ElInputNumber)
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
expect(input.props('max')).toBe(candidate.availablePackageCount)
input.vm.$emit('update:modelValue', 4)
} else {
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
expect(assignmentDialog().get('details').text()).toContain('I181:9 箱')
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
}
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
expect(assignmentDialog().classes()).toContain('is-fullscreen')
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
popup.vm.$emit('confirm'); await flushPromises()
if (unitType === 'PACKAGE') {
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 4, expectedVersion: plan.version })
expect(assignSmartLoadingPallet).not.toHaveBeenCalled()
} else {
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(plan.id, { palletId: '801', expectedVersion: plan.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
}
expect(popup.props('modelValue')).toBe(false)
wrapper.unmount()
})
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
@@ -131,6 +223,7 @@ describe('GlobalSmartLoadingView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 50, total: 0, pages: 0 })
mockContainerSearch([container])
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
@@ -139,6 +232,7 @@ describe('GlobalSmartLoadingView', () => {
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
@@ -219,12 +313,13 @@ describe('GlobalSmartLoadingView', () => {
const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待补录')
expect(option.get('.container-option__top strong').text()).toBe('待生成')
expect(option.text()).not.toContain('空柜')
expect(option.text()).toContain('集装箱:待补录')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HQ')
expect(option.get('.container-option__meta').text()).toContain('义乌国内仓')
expect(option.get('.container-option__meta').text()).toContain('40HQ')
expect(option.text()).not.toContain('2088475410134773762')
expect(wrapper.text()).not.toContain('真实柜号')
})
@@ -249,7 +344,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).not.toContain('MSCU-2')
expect(wrapper.text()).toContain('柜子数量1 个')
expect(wrapper.text()).toContain('柜次1 个')
expect(wrapper.findAll('.channel-tags .el-checkbox-button').map((item) => item.text())).toEqual([
'美森正班', '美森加班', 'EXX', '合德', '洛杉矶普船', '芝加哥普船', '休斯顿普船', '萨凡纳普船', '纽约普船海卡',
@@ -261,7 +356,7 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.findAll('.container-option')).toHaveLength(2)
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('MSCU-2')
expect(wrapper.text()).toContain('柜子数量2 个')
expect(wrapper.text()).toContain('柜次2 个')
})
it('selects only the double-clicked channel as a shortcut', async () => {
@@ -411,12 +506,35 @@ describe('GlobalSmartLoadingView', () => {
it('sends the active name-only customer selector to the server candidate query', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain('useActiveCustomerOptions(')
expect(source).toContain('remoteMethod: loadCustomers')
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('params.customerId = customerId')
expect(source).not.toContain('customer.tenantCode')
})
it('searches customers remotely, loads more options, and applies the selected customer to candidates', async () => {
const target = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockImplementation(async query => ({
records: query.keyword || query.page === 2 ? [target] : [{ id: '1', tenantName: '跨客户车次-测试客户', status: 'ACTIVE' } as PlatformTenantResource],
page: query.page, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7,
}))
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '全部客户')!
expect(selector.props('remote')).toBe(true)
selector.vm.$emit('end-reached', 'bottom'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' })
await selector.props('remoteMethod')?.('柏悦'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' })
expect(selector.findAllComponents(ElOption).map(option => option.props('label'))).toContain('柏悦物流')
selector.vm.$emit('update:modelValue', target.id); await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('search', toolbar.props('modelValue')); await flushPromises()
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: target.id }))
wrapper.unmount()
})
it('defaults ordinary candidate assignment to all available boxes', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain(': candidate.availablePackageCount')
@@ -497,9 +615,14 @@ describe('GlobalSmartLoadingView', () => {
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -533,10 +656,12 @@ describe('GlobalSmartLoadingView', () => {
expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
expect(document.body.textContent).toContain('订单渠道:合德海卡')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
const measurements = assignmentDialog().findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
const channel = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
;([...document.body.querySelectorAll('button')]
@@ -699,6 +824,8 @@ describe('GlobalSmartLoadingView', () => {
{ value: 'LOADING', label: '装柜中' },
])
expect(fields.find((field) => field.key === 'orderStatuses')?.props).toMatchObject({ multiple: true, collapseTags: true, maxCollapseTags: 2 })
expect(fields.find((field) => field.key === 'createdRange')?.props).toMatchObject({ startPlaceholder: '创建起始', endPlaceholder: '创建截止', 'aria-label': '创建日期范围' })
expect(fields.find((field) => field.key === 'expectedArrivalRange')?.props).toMatchObject({ startPlaceholder: '到货起始', endPlaceholder: '到货截止', 'aria-label': '预计到货日期范围' })
expect(source).not.toContain('目的仓')
expect(wrapper.text()).not.toContain('税率')
@@ -810,6 +937,42 @@ describe('GlobalSmartLoadingView', () => {
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
})
it('keeps assignment failures and diagnostic IDs inside the open dialog without losing the quantity', async () => {
vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
vi.mocked(assignSmartLoadingItem).mockRejectedValueOnce(new AxiosError(
'forbidden', 'ERR_BAD_REQUEST', undefined, undefined,
{ data: { code: 'A0403', message: 'Access denied', requestId: 'assign-request', traceId: 'assign-trace' }, status: 403, statusText: 'Forbidden', headers: {}, config: {} as never },
))
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
await flushPromises()
const popup = wrapper.getComponent(AppDialog)
const input = popup.getComponent(ElInputNumber)
input.vm.$emit('update:modelValue', 7)
await flushPromises()
// 箱数与上方资料使用同一标签列,保留输入名称与原范围。
const label = assignmentDialog().get('label[for="global-loading-assignment-count"]')
expect(label.element.parentElement?.tagName).toBe('TH')
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
expect(assignmentDialog().find('input#global-loading-assignment-count').exists()).toBe(true)
expect(input.props('max')).toBe(candidate.availablePackageCount)
popup.vm.$emit('confirm')
await flushPromises()
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 7, expectedVersion: plan.version })
expect(popup.props('modelValue')).toBe(true)
expect(input.props('modelValue')).toBe(7)
expect(assignmentDialog().text()).toContain('加入当前柜次失败')
expect(assignmentDialog().text()).toContain('当前账号无权访问该数据')
expect(assignmentDialog().text()).toContain('assign-request')
expect(assignmentDialog().text()).toContain('assign-trace')
expect(assignmentDialog().findAll('.load-error-state')).toHaveLength(1)
expect(wrapper.get('.global-loading-page').findAll('.load-error-state')).toHaveLength(0)
wrapper.unmount()
})
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
const warningMessage = vi.spyOn(ElMessage, 'warning').mockImplementation(() => undefined as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
@@ -876,6 +1039,7 @@ describe('GlobalSmartLoadingView', () => {
await removeAllButton!.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.any(String), '移除未拣明细 · CB-1', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
@@ -1020,7 +1184,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('size="small"')
expect(source).toContain('type="danger" plain')
expect(source).toContain(':max="adjustmentMax(row)"')
expect(source).toContain("{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }")
expect(source).toContain("{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'customerName' }")
expect(source).not.toContain('link type="danger"')
expect(source).toContain('@media (max-width: 720px)')
expect(source.match(/<DataTable[\s\S]*?\bstripe\b/g)).toHaveLength(2)
@@ -1030,9 +1194,9 @@ describe('GlobalSmartLoadingView', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('height: clamp(520px, 68vh, 720px); min-height: 0;')
expect(source).toContain('grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr);')
expect(source).toContain('grid-template-columns: minmax(0, 1.05fr) 240px minmax(0, 1fr);')
expect(source).toContain('.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }')
expect(source).toContain('.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }')
expect(source).toContain('.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }')
expect(source).toContain(".candidate-pane :deep(.data-table__page-button[aria-label^='第']),.candidate-pane :deep(.data-table__page-ellipsis) { display: none; }")
expect(source).toContain('.candidate-pane :deep(.data-table__page-size .el-select) { width: 68px; }')
expect(source).toContain('.container-list { display: grid; min-height: 0; flex: 1;')
@@ -1040,11 +1204,12 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('overflow-wrap: anywhere;')
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
expect(source).toContain('min-height: 112px')
expect(source).toContain('gap: var(--admin-space-1); padding: var(--admin-space-4);')
expect(source).toContain('.candidate-pane :deep(.data-table__pagination) { flex: 0 0 auto; }')
expect(source).toContain("label: '创建日期'")
expect(source).toContain("label: '预计到货时间'")
expect(source).toContain("label: '订单状态'")
expect(source).toContain('.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }')
expect(source).toContain('.smart-loading-query .query-toolbar__filters) { flex-wrap: wrap; gap: 8px; }')
expect(source).toContain('.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }')
expect(source).toContain(':page-sizes="[20, 50, 100]"')
})
@@ -1059,14 +1224,14 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain("ElMessage.warning('至少选择一个渠道')")
expect(source).toContain('class="compact-summary" aria-label="已选渠道装柜统计"')
expect(source).toContain('<dt>候选明细</dt>')
expect(source).toContain('<dt>柜子数量</dt>')
expect(source).toContain('<dt>已装</dt>')
expect(source).toContain('<dt>总重量</dt>')
expect(source).toContain('<dt>总体积</dt>')
expect(source).toContain('<dt>柜次</dt>')
expect(source).toContain('<dt>当前柜次分配</dt>')
expect(source).toContain('formatNumber(totalWeight, 1)')
expect(source).toContain('formatNumber(totalVolume)')
expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)')
expect(source).toContain('flex-wrap: nowrap; gap: 6px;')
expect(source).toContain('height: 34px; padding: 0 10px;')
expect(source).toContain('font-size: 13px; line-height: 32px;')
expect(source).toContain('font-size: var(--admin-font-body); line-height: 32px;')
expect(source).not.toContain('class="summary-band"')
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
@@ -1,10 +1,12 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -48,8 +50,6 @@ const editingQuantityId = ref('')
const access = useAccessPreviewStore()
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
const selectedChannelCodes = ref<string[]>([])
const plan = ref<SmartLoadingPlan | null>(null)
@@ -65,10 +65,17 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignmentTitle = computed(() => {
const candidate = selectedCandidate.value
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
return code ? `${action} · ${code}` : action
})
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], expectedArrivalRange: [], orderStatuses: [] })
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => String(query.value.customerId || ''), listPlatformTenants)
const appliedQuery = ref<QueryModel>({ ...query.value, orderStatuses: [] })
const candidatePage = ref(1)
const candidatePageSize = ref(20)
@@ -209,10 +216,10 @@ function candidateStatusLabel(status: string | null | undefined) {
}
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '发货仓', placeholder: '全部发货仓', width: 160, options: warehouseOptions.value, props: { filterable: true } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, remote: true, remoteMethod: loadCustomers, onEndReached: customerEndReached, loading: customersLoading.value } },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 260 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260, props: { startPlaceholder: '创建起始', endPlaceholder: '创建截止', 'aria-label': '创建日期范围' } },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 260, props: { startPlaceholder: '到货起始', endPlaceholder: '到货截止', 'aria-label': '预计到货日期范围' } },
{ key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } },
])
@@ -233,28 +240,27 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'inboundCode', label: '入仓码', minWidth: 180, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'planInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'inboundCode', label: '入仓码', minWidth: 180, fixed: 'left', slotName: 'planInbounds' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
]
function formatNumber(value: number, digits = 3) {
@@ -444,17 +450,6 @@ async function loadAll(preserved: Record<string, number> = {}) {
}
}
async function load() { await loadAll() }
async function loadCustomers() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customers.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customers.value = []
} finally {
customersLoading.value = false
}
}
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
@@ -499,6 +494,7 @@ function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
mutationError.value = null
assignForm.packageCount = candidate.coLoadGroupId
? Number(candidate.groupTotalBoxes || candidate.availablePackageCount)
: candidate.availablePackageCount
@@ -590,6 +586,10 @@ async function adjust(source: object) {
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
function workbenchDialogTitle(action: string) {
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
return code ? `${action} · ${code}` : action
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
@@ -600,8 +600,8 @@ async function remove(source: object) {
: sealedPallet
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -632,8 +632,8 @@ async function removeAll() {
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
if (!removableItems.length) return
const itemCount = removableItems.length
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
@@ -676,8 +676,8 @@ onMounted(() => { void load(); void loadCustomers() })
</script>
<template>
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page">
<component class="admin-density admin-workbench" :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
<section class="global-loading-page admin-workbench">
<div v-if="channelOptions.length" class="channel-summary-row">
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
<span class="channel-tags__label">渠道</span>
@@ -692,10 +692,8 @@ onMounted(() => { void load(); void loadCustomers() })
</el-checkbox-group>
<dl class="compact-summary" aria-label="已选渠道装柜统计">
<div><dt>候选明细</dt><dd><strong>{{ candidateTotal }}</strong> 条</dd></div>
<div><dt>柜子数量</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
<div><dt>柜次</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>当前柜次分配</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
</dl>
</div>
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少计划渠道,已隔离;请重新创建模拟柜`" />
@@ -712,11 +710,14 @@ onMounted(() => { void load(); void loadCustomers() })
@reset="applyFilters"
/>
<LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers">
<template #details><ApiErrorMeta :details="customersError" /></template>
</LoadErrorState>
<LoadErrorState v-if="loadError" title="智能装柜工作台加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新工作台" @retry="loadWorkbenchData()">
<LoadErrorState v-if="mutationError && !assignOpen" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新工作台" @retry="loadWorkbenchData()">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
<section class="dispatch-grid" aria-label="智能装柜调度台">
@@ -725,7 +726,7 @@ onMounted(() => { void load(); void loadCustomers() })
<LoadErrorState v-if="candidateError" title="候选明细加载失败" :message="candidateError.message" compact @retry="loadCandidateData()">
<template #details><ApiErrorMeta :details="candidateError" /></template>
</LoadErrorState>
<DataTable
<div class="workbench-table"><DataTable
:page="candidatePage"
:page-size="candidatePageSize"
:data="displayedCandidates"
@@ -743,9 +744,10 @@ onMounted(() => { void load(); void loadCustomers() })
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
<template #actions="{ row }">
<el-button
class="density-action" data-tone="primary"
v-if="canAssign && assignable"
size="small"
type="text"
type="primary"
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
@@ -761,7 +763,7 @@ onMounted(() => { void load(); void loadCustomers() })
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
<template #estimatedVolume="{ row }">{{ row.estimatedVolumeCbm == null ? '—' : `${formatNumber(Number(row.estimatedVolumeCbm))} m³` }}</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</DataTable></div>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
@@ -774,18 +776,18 @@ onMounted(() => { void load(); void loadCustomers() })
type="button"
class="container-option"
:class="{ selected: item.bookingId === selectedContainerId }"
:aria-pressed="item.bookingId === selectedContainerId"
@click="selectContainer(item.bookingId)"
>
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
<strong>{{ displayCabinetNo(item) }}</strong>
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
{{ hasRealContainerNo(item) ? '已录柜号' : '模拟柜次' }}
</el-tag>
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span class="container-option__identity">集装箱:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ containerChannelGroup(item)?.name || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span class="container-option__meta"><span>{{ item.warehouseName || '仓库待补充' }}</span><span>{{ item.containerType || '柜型待补充' }}</span></span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
</div>
@@ -824,40 +826,51 @@ onMounted(() => { void load(); void loadCustomers() })
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<div class="workbench-table"><DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
</DataTable></div>
</template>
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<AppDialog class="smart-loading-assignment-dialog admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" append-to-body fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<LoadErrorState v-if="mutationError" title="加入当前柜次失败" :message="mutationError.message" compact :show-retry="false">
<template #details><ApiErrorMeta :details="mutationError" /></template>
</LoadErrorState>
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<div class="workbench-evidence"><table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
</tbody></table></div>
</section>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<section class="density-dialog-section">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<template v-else>
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
<tr><th><label for="global-loading-assignment-count"><span class="assignment-required" aria-hidden="true">*</span>本次分配箱数</label></th><td colspan="3"><el-form-item><el-input-number id="global-loading-assignment-count" v-model="assignForm.packageCount" aria-label="本次分配箱数" aria-required="true" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
</template>
</tbody></table></div>
</el-form>
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
</template>
</div>
</AppDialog>
@@ -867,64 +880,73 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.smart-loading-assignment-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
.assignment-context-table { table-layout: fixed; }
.assignment-context-table th { width: 120px; }
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface); }
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; }
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--workbench-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: var(--admin-font-body); line-height: 32px; }
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; white-space: nowrap; }
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
.compact-summary dt { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.compact-summary dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-caption); }
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: wrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: wrap; gap: 8px; }
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
.pane-header small,.container-option span,.global-loading-page small { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) 240px minmax(0, 1fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; }
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
.dispatch-pane > .workbench-table { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--workbench-border); }
.pane-header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface); }
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { min-height: 0; height: auto; flex: 1; }
.plan-pane :deep(.el-table__empty-block) { min-height: 220px; }
.candidate-pane :deep(.data-table__pagination) { flex: 0 0 auto; }
.candidate-pane :deep(.data-table__pagination) { min-height: 46px; flex-wrap: nowrap; gap: 8px; padding: 7px 8px; }
.candidate-pane :deep(.data-table__pagination-left) { flex: 0 0 auto; white-space: nowrap; }
.candidate-pane :deep(.data-table__pagination-controls),.candidate-pane :deep(.data-table__page-size),.candidate-pane :deep(.data-table__page-buttons) { flex-wrap: nowrap; gap: 4px; white-space: nowrap; }
.candidate-pane :deep(.data-table__page-button[aria-label^='第']),.candidate-pane :deep(.data-table__page-ellipsis) { display: none; }
.candidate-pane :deep(.data-table__page-size .el-select) { width: 68px; }
.container-pane { background: #fafafa; }
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.container-pane { background: var(--workbench-surface-soft); }
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: var(--admin-space-3); padding: var(--admin-space-3); overflow-y: auto; }
.container-option { display: grid; width: 100%; height: auto; align-content: start; gap: var(--admin-space-1); padding: var(--admin-space-4); overflow: hidden; border: 1px solid var(--workbench-border); border-radius: var(--workbench-tab-radius); background: var(--workbench-surface); color: var(--workbench-text); line-height: 1.4; text-align: left; cursor: pointer; }
.container-option:hover { border-color: var(--workbench-primary); }
.container-option:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
.container-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
.container-option__top strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
.container-option__top .el-tag { flex: 0 0 auto; }
.container-option__meta { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
.container-option__identity { overflow-wrap: anywhere; }
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
.plan-header { flex-wrap: wrap; }
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--workbench-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.readonly-status-card strong { font-size: var(--admin-font-title); }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--workbench-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.package-count-summary strong { font-size: 13px; font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; }
@@ -933,7 +955,7 @@ onMounted(() => { void load(); void loadCustomers() })
:deep(.smart-loading-query .query-toolbar__filters),:deep(.smart-loading-query .query-toolbar__tail),:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: wrap; overflow: visible; }
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.embedded-smart-loading .dispatch-grid { height: auto; }
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--workbench-border); border-left: 0; }
.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }
.candidate-pane :deep(.data-table__pagination) { flex-wrap: wrap; }
.container-pane { max-height: min(58vh, 460px); }

Some files were not shown because too many files have changed in this diff Show More