Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f7dcc6f1 | ||
|
|
a86b19604d | ||
|
|
24ebfcf2e1 | ||
|
|
aac878c3d4 | ||
|
|
d49210575b | ||
|
|
30a0d7ce68 | ||
|
|
29f4791b66 | ||
|
|
3e77c3976e | ||
|
|
090431b409 | ||
|
|
ef85ed30e4 | ||
|
|
2e26477db5 | ||
|
|
6d2ef9fffc | ||
|
|
ad33b21869 | ||
|
|
93a31b3872 | ||
|
|
d8fbc16fd7 | ||
|
|
dbd7c473c2 | ||
|
|
875d2b2b04 | ||
|
|
e959a99db8 | ||
|
|
a4cb6c3757 | ||
|
|
c367327049 | ||
|
|
7c43fcacc4 | ||
|
|
2ecf4cf3fe | ||
|
|
bc27480e2c | ||
|
|
872d197531 | ||
|
|
b835c1cdfc | ||
|
|
ba072611dc | ||
|
|
98049c4f8c | ||
|
|
64667af5c6 | ||
|
|
4f5cec1b94 | ||
|
|
c367fadf09 | ||
|
|
fde16235ea | ||
|
|
22e60248d2 | ||
|
|
f8c9334e98 | ||
|
|
67acd02ea5 | ||
|
|
c510cc4e2e | ||
|
|
e2f99b4f4b | ||
|
|
5e8daad5a0 | ||
|
|
215cb7c6d5 | ||
|
|
8356e98ce3 | ||
|
|
6ab3bbe289 | ||
|
|
a4c7f8edd5 | ||
|
|
a9c0285e22 | ||
|
|
e6c864a2a6 | ||
|
|
457a95941d | ||
|
|
fc4c14e420 | ||
|
|
8498798a9c | ||
|
|
3953453090 | ||
|
|
2333bd8bab | ||
|
|
eb0b0f85d1 | ||
|
|
db3b5f7157 | ||
|
|
876930b07e | ||
|
|
0a1968a134 | ||
|
|
2f9a3330e5 |
@@ -3,9 +3,8 @@
|
|||||||
## 快速读取顺序
|
## 快速读取顺序
|
||||||
|
|
||||||
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
||||||
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
|
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
|
||||||
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
|
||||||
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
|
|
||||||
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.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` 对应章节。
|
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@
|
|||||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||||
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
||||||
|
|
||||||
## 开发验收身份
|
## 开发验收身份
|
||||||
|
|
||||||
@@ -121,7 +120,8 @@ pnpm typecheck
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
|
||||||
|
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
|
||||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `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/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/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/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/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/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/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ 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: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle: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: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||||
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
{ 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-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-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' },
|
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -122,11 +123,11 @@ onMounted(() => load())
|
|||||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
<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 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>
|
<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 class="density-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
<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}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
<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()"></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>
|
<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" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
|
<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><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,11 +2,45 @@ import { mount } from '@vue/test-utils'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
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 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>' } } } })
|
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', () => {
|
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', () => {
|
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||||
@@ -74,7 +108,8 @@ describe('four-type appointment calendar', () => {
|
|||||||
expect(wrapper.text()).toContain('正在读取安排')
|
expect(wrapper.text()).toContain('正在读取安排')
|
||||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||||
await wrapper.setProps({ loading: false, incomplete: true })
|
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('当天暂无安排')
|
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function changeMode(next: CalendarMode) {
|
|||||||
</header>
|
</header>
|
||||||
<div class="calendar-options">
|
<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>
|
<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 class="workbench-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>
|
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||||
@@ -70,9 +70,9 @@ function changeMode(next: CalendarMode) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
|
<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> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
<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">
|
<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">
|
<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>
|
<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>
|
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ElMessageBox } from 'element-plus'
|
|||||||
import { ApiResponseError } from '@/shared/api/http'
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||||
import { listTripCalendar } from './tripCalendar'
|
import { listTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||||
import type { ScheduleEvent } from './schedule'
|
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>() }))
|
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('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
@@ -16,6 +16,9 @@ vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('
|
|||||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
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 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 }
|
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() {
|
export function renderSchedule() {
|
||||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||||
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||||
@@ -147,7 +150,7 @@ describe('simplified appointment entry', () => {
|
|||||||
})
|
})
|
||||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||||
routing.denied.add('platform:fleet:trip:list')
|
routing.denied.add('platform:fleet:trip:list')
|
||||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
|
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] })
|
||||||
const wrapper = renderSchedule(); await flushPromises()
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||||
@@ -158,4 +161,21 @@ describe('simplified appointment entry', () => {
|
|||||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||||
wrapper.unmount()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
|
|||||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
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 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 },
|
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||||
let generation = 0, controller: AbortController | null = null
|
let generation = 0, controller: AbortController | null = null
|
||||||
@@ -46,6 +47,7 @@ async function load(next = range.value) {
|
|||||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||||
const signal = controller.signal
|
const signal = controller.signal
|
||||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
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) }) }
|
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -71,7 +73,7 @@ async function load(next = range.value) {
|
|||||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||||
if (request !== generation) return
|
if (request !== generation) return
|
||||||
trips.value = result.events
|
trips.value = result.events
|
||||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
hasUnresolvedTrips.value = result.unresolved.length > 0
|
||||||
} catch (cause) { fail('排车读取失败', cause) }
|
} catch (cause) { fail('排车读取失败', cause) }
|
||||||
})(),
|
})(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -30,6 +30,12 @@ async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
|||||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||||
await flushPromises()
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks(); guards.clear()
|
vi.clearAllMocks(); guards.clear()
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [
|
|||||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||||
{ prop: 'packages', label: '箱数', width: 80 },
|
{ prop: 'packages', label: '箱数', width: 80 },
|
||||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
|
||||||
]
|
]
|
||||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||||
function reason(row: PlanningCargo) {
|
function reason(row: PlanningCargo) {
|
||||||
if (row.detained) return '货物暂扣中'
|
if (row.detained) return '货物暂扣中'
|
||||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||||
if (row.hasActiveAppointment) return '已有有效预约'
|
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
|
||||||
const anchor = selectedRows.value[0]
|
const anchor = selectedRows.value[0]
|
||||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -8,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
||||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||||
const props = defineProps<{ source: CargoVerificationSource }>()
|
const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
|
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 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 form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
|
||||||
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
||||||
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
|
const canLeave = 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'))
|
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) {
|
async function load(preserve = false) {
|
||||||
loading.value = true; error.value = null
|
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 }
|
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
|
||||||
@@ -24,7 +27,7 @@ async function load(preserve = false) {
|
|||||||
context.value = await getCargoVerification(props.source)
|
context.value = await getCargoVerification(props.source)
|
||||||
if (!preserve) {
|
if (!preserve) {
|
||||||
const record = context.value.verification
|
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)
|
baseline.value = JSON.stringify(form)
|
||||||
}
|
}
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
||||||
@@ -37,6 +40,7 @@ async function save() {
|
|||||||
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
|
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 }
|
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))]
|
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 }
|
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() }
|
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() }
|
||||||
}
|
}
|
||||||
@@ -54,19 +58,20 @@ onMounted(() => load())
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section aria-label="货物核实资料">
|
<section aria-label="货物核实资料">
|
||||||
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
<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>
|
<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>
|
<p v-if="loading">正在读取货物资料…</p>
|
||||||
<template v-if="context">
|
<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>
|
<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">
|
<form class="density-form work-form appointment-native-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>{{ 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="本票货件号" /></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" /></label>
|
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
|
||||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||||
<LoadErrorState v-if="saveError" title="核实资料未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
<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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 预约工作日历逐操作矩阵
|
# 预约工作日历逐操作矩阵
|
||||||
|
|
||||||
|
## 2026-10-21 排车候选分页
|
||||||
|
|
||||||
|
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||||
|
|
||||||
## 2026-10-20 日期时间组件统一(当前确认)
|
## 2026-10-20 日期时间组件统一(当前确认)
|
||||||
|
|
||||||
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
|
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
|
||||||
@@ -265,3 +271,15 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
|||||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||||
|
|
||||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
|
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、事件、权限/禁用条件均一致。类型检查、全量回归与构建由总控统一收口。
|
||||||
|
|||||||
@@ -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('@/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() }))
|
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' }
|
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) })
|
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 () => {
|
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
|
||||||
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
|
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()
|
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 }
|
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 () => {
|
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
|
||||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
|
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
|
||||||
const wrapper = render(); await flushPromises()
|
const wrapper = render(); await flushPromises()
|
||||||
expect(wrapper.text()).toContain('货物分配仍保留')
|
expect(wrapper.text()).toContain('货物分配仍保留')
|
||||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
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()
|
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]
|
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||||
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
|
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
|
||||||
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
|
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
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 props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
||||||
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
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 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)
|
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@@ -26,7 +30,7 @@ async function load(preserve = false) {
|
|||||||
try {
|
try {
|
||||||
detail.value = await getFbaAppointment(props.appointmentNo)
|
detail.value = await getFbaAppointment(props.appointmentNo)
|
||||||
if (!preserve || !initialized) {
|
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
|
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() }
|
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
|
||||||
} else {
|
} else {
|
||||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
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() } }
|
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +64,7 @@ async function save() {
|
|||||||
onMounted(() => load())
|
onMounted(() => load())
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="work-form" aria-label="预约安排">
|
<section class="work-form appointment-native-form" aria-label="预约安排">
|
||||||
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
|
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
|
||||||
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<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>
|
<p v-if="loading">正在读取原预约…</p>
|
||||||
@@ -67,8 +74,8 @@ onMounted(() => load())
|
|||||||
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
|
<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 && !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>
|
<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>
|
<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 :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
<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>
|
<p v-if="!writable">当前预约仅可查看。</p>
|
||||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
<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>
|
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -86,20 +88,20 @@ async function save() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<form class="density-form work-form" @submit.prevent="save">
|
<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>
|
<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="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
|
||||||
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
||||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
<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-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><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>下次跟进日期(可留空)<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 === '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" /></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" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></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"><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 === '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" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></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>
|
</fieldset>
|
||||||
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<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>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>
|
<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 () => {
|
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
|
||||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
|
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
|
||||||
const wrapper = render(); await flushPromises()
|
const wrapper = render(); await flushPromises()
|
||||||
expect(wrapper.text()).toContain('货物备妥窗口待核实')
|
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
|
||||||
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
|
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
|
||||||
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
|
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
|
||||||
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
||||||
wrapper.unmount()
|
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()
|
const wrapper = render(); await flushPromises()
|
||||||
|
await confirmRegistration(wrapper)
|
||||||
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
|
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
|
||||||
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
|
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()
|
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 () => {
|
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
|
||||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
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]
|
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||||
expect(await guard()).toBe(false)
|
expect(await guard()).toBe(false)
|
||||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
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 () => {
|
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
|
||||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
|
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
|
||||||
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
|
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
|
||||||
|
await confirmRegistration(wrapper)
|
||||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||||
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
|
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
|
||||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
|
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import { workReason } from './workPresentation'
|
|||||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
||||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
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 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 loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||||
const pending = ref<SourceAppointmentPayload | null>(null)
|
const pending = ref<SourceAppointmentPayload | null>(null)
|
||||||
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
|
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))
|
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
|
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
|
||||||
@@ -29,6 +32,9 @@ async function save() {
|
|||||||
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
|
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
|
||||||
if (!pending.value) {
|
if (!pending.value) {
|
||||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
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() }
|
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
|
saving.value = true; saveError.value = null
|
||||||
@@ -55,10 +61,9 @@ onMounted(load)
|
|||||||
<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>{{ 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>
|
<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>
|
</tbody></table>
|
||||||
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||||
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
|
||||||
</section>
|
</section>
|
||||||
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
<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>
|
<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"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
|||||||
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
|
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(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(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
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(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(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(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) })
|
||||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
|
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 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: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
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)
|
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
|
||||||
const wrapper = render()
|
const wrapper = render()
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +98,7 @@ it('uses a server-owned draft, explicit zero versions, native source hashes and
|
|||||||
})
|
})
|
||||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
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 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').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
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
|
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()
|
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()
|
||||||
})
|
})
|
||||||
@@ -112,12 +112,12 @@ it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo
|
|||||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
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)))
|
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: [] })
|
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
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()
|
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('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
|
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
|
||||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
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.get('input').attributes('disabled')).toBeDefined(); missing.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 })
|
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()
|
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||||
})
|
})
|
||||||
@@ -160,3 +160,38 @@ it('shows batch-level blockers even without rows and refuses duplicate physical
|
|||||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
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()
|
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('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,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, 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 { 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -21,14 +23,20 @@ type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; di
|
|||||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
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 contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
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 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 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 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 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 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 writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
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)
|
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||||
@@ -43,7 +51,7 @@ function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encod
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!readable.value || pending.value || corrupted.value) return
|
if (!readable.value || pending.value || corrupted.value) return
|
||||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||||
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
|
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
|
||||||
try {
|
try {
|
||||||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||||||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||||||
@@ -66,16 +74,24 @@ async function load() {
|
|||||||
if (current !== generation) return
|
if (current !== generation) return
|
||||||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||||||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||||||
contexts.value = batchCandidates
|
contexts.value = batchCandidates; receivingContexts = receiving
|
||||||
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
await loadCandidatePage(true)
|
||||||
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||||
if (current !== generation) return
|
finally { if (current === generation) working.value = false }
|
||||||
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
}
|
||||||
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
async function loadCandidatePage(autoSelect = false) {
|
||||||
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
const sequence = ++nativeGeneration, current = generation
|
||||||
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
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 candidate = matches.length === 1 ? matches[0]! : null
|
||||||
const sources = receiving[index]!.batch.items
|
const sources = receivingContexts[index]!.batch.items
|
||||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
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 (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||||
@@ -84,20 +100,25 @@ async function load() {
|
|||||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.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)] }
|
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('同一实物出现在多条批次候选,请先核对分配范围')
|
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
||||||
const eligible = rows.value.filter(row => !reasons(row).length)
|
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
|
||||||
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
const eligible = rows.value.filter(row => !reasons(row).length)
|
||||||
baseline.value = JSON.stringify(selected.value)
|
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
|
||||||
finally { if (current === generation) loading.value = false }
|
baseline.value = JSON.stringify(selected.value)
|
||||||
|
}
|
||||||
|
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||||
}
|
}
|
||||||
|
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) {
|
function select(row: BatchRow, event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
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)
|
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||||||
else if (!selected.value.includes(row.key)) {
|
else if (!selected.value.includes(row.key)) {
|
||||||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||||||
else selected.value.push(row.key)
|
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
|
||||||
}
|
}
|
||||||
input.checked = selected.value.includes(row.key)
|
input.checked = selected.value.includes(row.key)
|
||||||
}
|
}
|
||||||
@@ -118,7 +139,7 @@ async function executeStep() {
|
|||||||
if (command.action === 'CONVERT') {
|
if (command.action === 'CONVERT') {
|
||||||
const converted = value as ConvertedDraft
|
const converted = value as ConvertedDraft
|
||||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
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 }, attempted: true, rejected: false, result: null })
|
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
|
||||||
request.converted = converted
|
request.converted = converted
|
||||||
} else {
|
} else {
|
||||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||||
@@ -153,6 +174,7 @@ async function save() {
|
|||||||
ensureOwner()
|
ensureOwner()
|
||||||
if (!pending.value) {
|
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('请核对本仓实物资格、来源批次与计划托位')
|
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()
|
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()
|
||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -168,7 +190,7 @@ async function save() {
|
|||||||
else {
|
else {
|
||||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
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('草稿真实来源尚未完整恢复,请保留原请求核对')
|
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: { 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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,10 +222,10 @@ async function releaseRejected() {
|
|||||||
}
|
}
|
||||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; 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)
|
onMounted(load)
|
||||||
onBeforeUnmount(() => { generation++ })
|
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
<section class="work-form appointment-native-form" aria-label="按预约批次创建整车排车">
|
||||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
@@ -216,8 +238,10 @@ onBeforeUnmount(() => { generation++ })
|
|||||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
<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>
|
<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>
|
<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"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
<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>
|
<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>
|
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
|
|||||||
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
|
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.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.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
|
return c
|
||||||
}
|
}
|
||||||
async function enterBPlan(wrapper: ReturnType<typeof render>) {
|
async function enterBPlan(wrapper: ReturnType<typeof render>) {
|
||||||
@@ -373,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 () => {
|
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
|
||||||
for (const ownPlan of [false, true]) {
|
for (const ownPlan of [false, true]) {
|
||||||
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
|
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)
|
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||||
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
|
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
|
||||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
@@ -142,7 +143,7 @@ async function openPlan(id: string) {
|
|||||||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
|
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
|
||||||
const saved = customerPlan(id)
|
const saved = customerPlan(id)
|
||||||
const header = context.value!.batch
|
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: '' })
|
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()
|
baseline.value = state()
|
||||||
}
|
}
|
||||||
@@ -265,7 +266,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
|||||||
<ReceivingScope :members="[]" :transport="context.transport" />
|
<ReceivingScope :members="[]" :transport="context.transport" />
|
||||||
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
|
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
|
||||||
</template>
|
</template>
|
||||||
<form v-else class="density-form" @submit.prevent="save()">
|
<form v-else class="density-form appointment-native-form" @submit.prevent="save()">
|
||||||
<fieldset :disabled="locked || !formWritable">
|
<fieldset :disabled="locked || !formWritable">
|
||||||
<template v-if="mode === 'refresh'">
|
<template v-if="mode === 'refresh'">
|
||||||
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
|
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
|
||||||
@@ -283,19 +284,19 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
|||||||
<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.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.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>
|
<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"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
|
<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="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
<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="客户计划备注" /></label>
|
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" class="workbench-input" /></label>
|
||||||
|
|
||||||
</template>
|
</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>后送目标日期<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="调整负责人"><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="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></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="仓库回复依据" /></label></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="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 === '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="整份回复依据" /></label></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>
|
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
|
||||||
</fieldset>
|
</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>
|
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
|
||||||
</form>
|
</form>
|
||||||
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||||
import type { LeaveGuard } from './useWorkDraft'
|
import type { LeaveGuard } from './useWorkDraft'
|
||||||
const access = vi.hoisted(() => ({ allowed: true }))
|
const access = vi.hoisted(() => ({ allowed: true }))
|
||||||
const identity = vi.hoisted(() => ({ id: '' }))
|
const identity = vi.hoisted(() => ({ id: '' }))
|
||||||
@@ -25,11 +25,14 @@ it('honors native stage/gate and permissions and rejects a different returned bu
|
|||||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.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()
|
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
|
||||||
})
|
})
|
||||||
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
|
it('ignores an old add command without blocking the calendar or another action',async()=>{
|
||||||
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
|
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')).toBeDefined(); expect(await guard()).toBe(false)
|
const wrapper=render();await flushPromises()
|
||||||
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
|
||||||
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
|
expect(await guard()).toBe(true)
|
||||||
|
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
|
||||||
|
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||||
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('retries lookup as a read and keeps the original detail navigation', async () => {
|
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)
|
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)
|
||||||
@@ -50,22 +53,12 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection
|
|||||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
it('does not offer a stored command replay when opening the form again',async()=>{
|
||||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}}))
|
||||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
const wrapper=render();await flushPromises()
|
||||||
recovery.pending.value!.attempted = true
|
expect(wrapper.text()).not.toContain('刷新加货资料')
|
||||||
const completed: CommandResult = {
|
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
|
||||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
||||||
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
|
|
||||||
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
|
|
||||||
}
|
|
||||||
recovery.pending.value!.result = completed
|
|
||||||
const wrapper = render(); await flushPromises()
|
|
||||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
|
||||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
|
||||||
await button(wrapper, '刷新加货资料').trigger('click')
|
|
||||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
|
||||||
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ async function load() {
|
|||||||
finally { if (current === generation) loading.value = false }
|
finally { if (current === generation) loading.value = false }
|
||||||
}
|
}
|
||||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
|
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
|
||||||
function recover() {
|
|
||||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
|
||||||
}
|
|
||||||
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
||||||
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||||
watch(() => props.dispatchNo, load, { immediate: true })
|
watch(() => props.dispatchNo, load, { immediate: true })
|
||||||
@@ -47,7 +44,6 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
|
|||||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></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">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||||
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></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>
|
<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" />
|
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
||||||
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
|
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
|
||||||
|
export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
|
||||||
|
export type { PlanningSourceLookup } from './planningApi'
|
||||||
|
|||||||
@@ -3,27 +3,39 @@ import { projectContainerSchedule } from './schedule'
|
|||||||
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
||||||
const range = { from: '2026-11-01', to: '2026-12-01' }
|
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 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', () => {
|
describe('four-type schedule projection', () => {
|
||||||
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
|
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: '柜内计划时间不一致,待核实' })
|
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
|
||||||
})
|
})
|
||||||
it('adds two local calendar days across months without creating a time', () => {
|
it('adds two local calendar days across months without creating a time', () => {
|
||||||
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
||||||
expect(events).toHaveLength(1)
|
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', () => {
|
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' }] }
|
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, 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 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
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', () => {
|
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)
|
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
|
||||||
expect(events).toHaveLength(1)
|
expect(events).toHaveLength(1)
|
||||||
expect(events[0]?.date).toBe('2026-11-08')
|
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', () => {
|
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]!
|
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')
|
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' })
|
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
|
||||||
})
|
})
|
||||||
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
|
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.map(row => row.date)).toEqual([null, null])
|
||||||
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
||||||
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { addDays } from './calendar'
|
import { projectWarehouseArrival } from '@/modules/container-loading'
|
||||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||||
|
|
||||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
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) {
|
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
|
||||||
const events: ScheduleEvent[] = []
|
const events: ScheduleEvent[] = []
|
||||||
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
|
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) {
|
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 },
|
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
|
||||||
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
||||||
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
|
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
|
if (warehouses === null) continue
|
||||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||||
warehouseMap.delete(row.containerBookingId)
|
warehouseMap.delete(row.containerBookingId)
|
||||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
const projected = projectWarehouseArrival(warehouse)
|
||||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
const planned = projected.date
|
||||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
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,
|
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 },
|
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||||
}
|
}
|
||||||
// 人工改期可能跨月,与当前月份的到港记录无关。
|
// 人工改期可能跨月,与当前月份的到港记录无关。
|
||||||
for (const row of warehouseMap.values()) {
|
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
|
if (!date || !inRange(date, range)) continue
|
||||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
|
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
|
||||||
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
title: `${row.cabinetNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
||||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
|
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) {
|
for (const row of sources.emptyReturns) {
|
||||||
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
||||||
if (!at || !inRange(at.slice(0, 10), range)) continue
|
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),
|
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' ? '已超过还柜截止时间' : '还柜截止提醒',
|
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
|
||||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
||||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async function readSchedule(overrides: Record<string, unknown> = {}) {
|
|||||||
|
|
||||||
describe('warehouse calendar response to schedule', () => {
|
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 => {
|
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 })
|
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(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).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: '2026-10-22' })
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface CalendarReturn {
|
|||||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||||
export interface WarehouseArrival {
|
export interface WarehouseArrival {
|
||||||
sourceContainerBookingId: string
|
sourceContainerBookingId: string
|
||||||
|
cabinetNo?: string | null
|
||||||
containerNo: string | null
|
containerNo: string | null
|
||||||
warehouseName: string | null
|
warehouseName: string | null
|
||||||
warehouseCode: string | null
|
warehouseCode: string | null
|
||||||
@@ -29,6 +30,8 @@ export interface WarehouseArrival {
|
|||||||
version: number
|
version: number
|
||||||
planCount: number
|
planCount: number
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
plannedDate?: string | null
|
||||||
|
isDefault?: boolean
|
||||||
}
|
}
|
||||||
export interface ScheduleRange { from: string; to: string }
|
export interface ScheduleRange { from: string; to: string }
|
||||||
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||||
|
|||||||
@@ -4,9 +4,23 @@ import { http } from '@/shared/api/http'
|
|||||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
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' }
|
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||||
return { sourceType: 'DISPATCH', dispatchNo, 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: [] }
|
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', () => {
|
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', () => {
|
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 row = trip('DS/REAL', '2026-10-10T12:00:00Z')
|
||||||
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ScheduleEvent } from './schedule'
|
|||||||
export interface TripCalendarEntry {
|
export interface TripCalendarEntry {
|
||||||
sourceType: 'DISPATCH'
|
sourceType: 'DISPATCH'
|
||||||
dispatchNo: string
|
dispatchNo: string
|
||||||
|
containerNumbers: string[]
|
||||||
status: string
|
status: string
|
||||||
originWarehouseId: string | null
|
originWarehouseId: string | null
|
||||||
originWarehouseCode: string | null
|
originWarehouseCode: string | null
|
||||||
@@ -72,14 +73,17 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
|||||||
}
|
}
|
||||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||||
|
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
|
||||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||||
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示',
|
||||||
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
||||||
fields: [
|
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.originWarehouseName || row.originWarehouseCode },
|
||||||
{ label: '地点时区', value: row.originTimezone },
|
{ label: '地点时区', value: row.originTimezone },
|
||||||
|
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
|
||||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
{ 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: 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 ? '时刻待核实' : '尚未记录' },
|
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
|
|||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
it('queries visible containers and updates the whole-container expected arrival', async () => {
|
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.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 } } as never)
|
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 listContainerArrivalTimes(['9001'])
|
||||||
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
|
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.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-') } })
|
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
|
estimatedWarehouseArrivalAt: string | null
|
||||||
actualWarehouseArrivalAt: string | null
|
actualWarehouseArrivalAt: string | null
|
||||||
version: number
|
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'
|
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
|
||||||
@@ -20,7 +30,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
|
|||||||
queryPath,
|
queryPath,
|
||||||
{ sourceContainerBookingIds },
|
{ sourceContainerBookingIds },
|
||||||
)
|
)
|
||||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
|
return (unwrapApiResponse(response) || []).map(normalizeArrival)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContainerExpectedArrival(
|
export async function updateContainerExpectedArrival(
|
||||||
@@ -33,5 +43,5 @@ export async function updateContainerExpectedArrival(
|
|||||||
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
|
||||||
)
|
)
|
||||||
const result = unwrapApiResponse(response)
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { http } from '@/shared/api/http'
|
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'
|
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||||
|
|
||||||
const customs = {
|
const customs = {
|
||||||
@@ -15,6 +15,22 @@ const customs = {
|
|||||||
describe('platform container customs API', () => {
|
describe('platform container customs API', () => {
|
||||||
beforeEach(() => vi.restoreAllMocks())
|
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 () => {
|
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -178,13 +178,13 @@ export async function getDeclarationBatches(loadingPlanId: string) {
|
|||||||
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
|
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
|
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
|
||||||
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') } })
|
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))
|
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
|
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))
|
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,12 +114,12 @@ describe('ContainerCustomsPanel', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
|
it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
|
||||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||||
const wrapper = await render('READY_TO_START')
|
const wrapper = await render('READY_TO_START')
|
||||||
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
|
||||||
expect(api.start).not.toHaveBeenCalled()
|
expect(api.start).not.toHaveBeenCalled()
|
||||||
confirm.mockRestore()
|
confirm.mockRestore()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -164,8 +164,7 @@ describe('ContainerCustomsPanel', () => {
|
|||||||
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
||||||
const wrapper = await render('READY_TO_START')
|
const wrapper = await render('READY_TO_START')
|
||||||
expect(wrapper.text()).toContain('开始报关')
|
expect(wrapper.text()).toContain('开始报关')
|
||||||
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
|
||||||
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
|
||||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||||
expect(wrapper.text()).toContain('客户退税资料')
|
expect(wrapper.text()).toContain('客户退税资料')
|
||||||
expect(wrapper.text()).toContain('报关行回执')
|
expect(wrapper.text()).toContain('报关行回执')
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ function clearanceResultLabel(value: ClearanceResultType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
|
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 allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
|
||||||
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
|
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
|
||||||
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
|
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
|
||||||
@@ -184,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
|
|||||||
async function start() {
|
async function start() {
|
||||||
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' })
|
||||||
} catch { return }
|
} catch { return }
|
||||||
actionLoading.value = true; actionError.value = null
|
actionLoading.value = true; actionError.value = null
|
||||||
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||||
@@ -315,12 +314,9 @@ onMounted(load)
|
|||||||
<template v-else-if="customs">
|
<template v-else-if="customs">
|
||||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<dl class="density-fields customs-summary" aria-label="报关概况">
|
<dl v-if="brokerSubmission" 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>
|
<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>
|
||||||
<template v-if="brokerSubmission">
|
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||||
<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>
|
</dl>
|
||||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||||
<h3>缺退税资料清单</h3>
|
<h3>缺退税资料清单</h3>
|
||||||
@@ -370,5 +366,5 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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(--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}.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(--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)}
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ onMounted(load)
|
|||||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</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>
|
</el-table>
|
||||||
<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">
|
<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" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form class="density-form" label-position="left">
|
<el-form class="density-form" label-position="left">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
<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><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><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><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>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
|
<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>
|
<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" />
|
<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>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
@@ -170,9 +170,9 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
|||||||
.overview-section p { margin: 5px 0; color: var(--workbench-muted); font-size: var(--admin-font-body); }
|
.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}
|
.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}
|
.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(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
|
.overview-panel .pda-summary { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--admin-space-4); }
|
||||||
.pda-progress dd { width: 100%; }
|
.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(--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}
|
.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 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
@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} }
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
|
|||||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import ContainerSimulationCreate from './ContainerSimulationCreate.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.
|
// 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
|
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||||
@@ -19,14 +21,26 @@ afterEach(() => { document.body.innerHTML = '' })
|
|||||||
|
|
||||||
describe('ContainerSimulationCreate opted-in form', () => {
|
describe('ContainerSimulationCreate opted-in form', () => {
|
||||||
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
|
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
|
||||||
const wrapper = mount(ContainerSimulationCreate, { props: { dense, loadWarehouseOptions: async () => [warehouse] }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
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 wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const dialog = wrapper.findComponent(AppDialog)
|
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))')
|
expect(dialog.props('width')).toBe('min(800px, calc(100vw - 32px))')
|
||||||
dialog.vm.$emit('confirm')
|
dialog.vm.$emit('confirm')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||||
|
await vi.waitFor(() => expect(dialogRoot.querySelectorAll('.el-form-item__error').length).toBeGreaterThan(0))
|
||||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||||
expect(fields).toHaveLength(3)
|
expect(fields).toHaveLength(3)
|
||||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||||
@@ -38,5 +52,17 @@ describe('ContainerSimulationCreate opted-in form', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ async function saveSimulation() {
|
|||||||
class="simulation-create-dialog"
|
class="simulation-create-dialog"
|
||||||
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense, 'admin-workbench': dense, 'is-dialog': dense }"
|
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense, 'admin-workbench': dense, 'is-dialog': dense }"
|
||||||
title="新增模拟柜"
|
title="新增模拟柜"
|
||||||
|
append-to-body
|
||||||
width="min(800px, calc(100vw - 32px))"
|
width="min(800px, calc(100vw - 32px))"
|
||||||
confirm-text="创建模拟柜"
|
confirm-text="创建模拟柜"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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">
|
<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" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form class="density-form" label-position="left" label-width="160px">
|
<el-form class="density-form" label-position="left" label-width="160px">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
<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="柜次号"><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="已放舱订舱及柜型需求" 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>
|
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from './api/platformContainerLoading'
|
export * from './api/platformContainerLoading'
|
||||||
|
export { listContainerArrivalTimes, type ContainerArrivalTime } from './api/containerArrivalTimes'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
export { projectWarehouseArrival } from './warehouseArrival'
|
||||||
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
|
||||||
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
|
||||||
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
|
||||||
|
|||||||
+9
-1
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('ContainerDetailView empty return', () => {
|
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 () => {
|
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
|
||||||
const wrapper = await mountDetail('DOMESTIC')
|
const wrapper = await mountDetail('DOMESTIC')
|
||||||
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
|
||||||
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
|
|||||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||||
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } 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(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 () => {
|
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
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 { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||||
|
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||||
@@ -91,6 +92,12 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
|||||||
}
|
}
|
||||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||||
const arrivalTime = ref<ContainerArrivalTime | 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 hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
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'))
|
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||||||
@@ -313,9 +320,6 @@ const overseasStageText = computed(() => {
|
|||||||
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
||||||
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
||||||
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
||||||
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
|
|
||||||
? '核对作业证据、报关资料与装柜货物。'
|
|
||||||
: '核对预装货物与 PDA 作业进度。')
|
|
||||||
const overseasDestinationText = computed(() => {
|
const overseasDestinationText = computed(() => {
|
||||||
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
||||||
.filter(Boolean).join(' / ')
|
.filter(Boolean).join(' / ')
|
||||||
@@ -726,11 +730,11 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" 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>
|
<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>
|
||||||
|
|
||||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
<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><p>{{ loadingListDescription }}</p></div></header>
|
<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" />
|
<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" />
|
<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)" />
|
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||||
@@ -748,24 +752,24 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||||
<AppDialog class="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="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="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" data-tone="work"><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="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="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="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="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" data-tone="work"><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="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="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">
|
<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>
|
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||||
<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>
|
<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>
|
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><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 density-table--editable 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>
|
<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>
|
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :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>
|
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table density-table--editable 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" />
|
<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>
|
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||||
@@ -775,6 +779,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||||
.container-workflow-table { table-layout: fixed; }
|
.container-workflow-table { table-layout: fixed; }
|
||||||
.direct-remark { white-space: pre-wrap; }
|
.direct-remark { white-space: pre-wrap; }
|
||||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
|
|||||||
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
|
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
|
||||||
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
|
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(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(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 })
|
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(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(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: [] })
|
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: [] })
|
||||||
@@ -304,7 +304,7 @@ describe('ContainersView', () => {
|
|||||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||||
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||||
await flushPromises()
|
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 () => {
|
it('uses stable view labels when cabinetNo is missing', async () => {
|
||||||
@@ -479,7 +479,9 @@ describe('ContainersView', () => {
|
|||||||
expect(source).not.toContain('canSmartLoading && row.containerNo')
|
expect(source).not.toContain('canSmartLoading && row.containerNo')
|
||||||
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
||||||
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
|
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 () => {
|
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
|
||||||
@@ -582,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) => {
|
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')
|
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
|
||||||
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type TerminalDrayageView,
|
type TerminalDrayageView,
|
||||||
} from '../api/terminalDrayages'
|
} from '../api/terminalDrayages'
|
||||||
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||||
|
import { projectWarehouseArrival } from '../warehouseArrival'
|
||||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||||
|
|
||||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||||
@@ -285,6 +286,12 @@ function optional(value: string | null) { return value?.trim() || null }
|
|||||||
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
|
||||||
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', 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 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 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 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] }
|
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
|
||||||
@@ -577,7 +584,7 @@ async function saveDrayageAction() {
|
|||||||
}
|
}
|
||||||
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
|
||||||
const current = arrivalTimes.value[row.id]
|
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)
|
arrivalSavingIds.value.push(row.id)
|
||||||
try {
|
try {
|
||||||
const updated = await updateContainerExpectedArrival(row.id, {
|
const updated = await updateContainerExpectedArrival(row.id, {
|
||||||
@@ -736,7 +743,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
|||||||
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
|
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
|
||||||
|
|
||||||
|
|
||||||
<section id="container-workbench-results" ><div class="workbench-table is-page-surface"><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="暂无货柜">
|
<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>
|
<template #toolbar-start>
|
||||||
<div class="container-scope-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="货柜完结状态">
|
<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 === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||||
@@ -756,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 #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 #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 #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 #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 #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 #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 #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
|
||||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||||
@@ -772,7 +779,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
|||||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
|
||||||
</DataTable></div></section>
|
</DataTable></div></section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus'
|
import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
|
||||||
import type { ContainerBookingResource } from '../types'
|
import type { ContainerBookingResource } from '../types'
|
||||||
import {
|
import {
|
||||||
assignPendingReviewSmartLoadingOrder,
|
assignPendingReviewSmartLoadingOrder,
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
removeSmartLoadingPallet,
|
removeSmartLoadingPallet,
|
||||||
} from '../api/platformContainerLoading'
|
} from '../api/platformContainerLoading'
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
@@ -120,7 +123,50 @@ 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', () => {
|
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) => {
|
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
|
||||||
if (unitType === 'SEALED_PALLET') {
|
if (unitType === 'SEALED_PALLET') {
|
||||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||||
@@ -138,7 +184,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const popup = wrapper.getComponent(AppDialog)
|
const popup = wrapper.getComponent(AppDialog)
|
||||||
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
expect(assignmentDialog().get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
||||||
if (unitType === 'PACKAGE') {
|
if (unitType === 'PACKAGE') {
|
||||||
const input = popup.getComponent(ElInputNumber)
|
const input = popup.getComponent(ElInputNumber)
|
||||||
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
|
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
|
||||||
@@ -146,11 +192,11 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
input.vm.$emit('update:modelValue', 4)
|
input.vm.$emit('update:modelValue', 4)
|
||||||
} else {
|
} else {
|
||||||
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
|
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
|
||||||
expect(popup.get('details').text()).toContain('I181:9 箱')
|
expect(assignmentDialog().get('details').text()).toContain('I181:9 箱')
|
||||||
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
|
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
|
||||||
}
|
}
|
||||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||||
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
expect(assignmentDialog().classes()).toContain('is-fullscreen')
|
||||||
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
|
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
|
||||||
popup.vm.$emit('confirm'); await flushPromises()
|
popup.vm.$emit('confirm'); await flushPromises()
|
||||||
if (unitType === 'PACKAGE') {
|
if (unitType === 'PACKAGE') {
|
||||||
@@ -569,14 +615,14 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(buttons).toHaveLength(1)
|
expect(buttons).toHaveLength(1)
|
||||||
await buttons[0].trigger('click')
|
await buttons[0].trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
|
||||||
const popup = wrapper.getComponent(AppDialog)
|
const popup = wrapper.getComponent(AppDialog)
|
||||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||||
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
|
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||||
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
|
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
|
||||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||||
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -612,9 +658,9 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
const popup = wrapper.getComponent(AppDialog)
|
const popup = wrapper.getComponent(AppDialog)
|
||||||
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
|
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
|
||||||
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
const measurements = assignmentDialog().findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
||||||
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
|
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
|
||||||
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
const channel = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
||||||
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
|
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
|
||||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||||
@@ -907,10 +953,10 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// 箱数与上方资料使用同一标签列,保留输入名称与原范围。
|
// 箱数与上方资料使用同一标签列,保留输入名称与原范围。
|
||||||
const label = popup.get('label[for="global-loading-assignment-count"]')
|
const label = assignmentDialog().get('label[for="global-loading-assignment-count"]')
|
||||||
expect(label.element.parentElement?.tagName).toBe('TH')
|
expect(label.element.parentElement?.tagName).toBe('TH')
|
||||||
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
|
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
|
||||||
expect(popup.find('input#global-loading-assignment-count').exists()).toBe(true)
|
expect(assignmentDialog().find('input#global-loading-assignment-count').exists()).toBe(true)
|
||||||
expect(input.props('max')).toBe(candidate.availablePackageCount)
|
expect(input.props('max')).toBe(candidate.availablePackageCount)
|
||||||
popup.vm.$emit('confirm')
|
popup.vm.$emit('confirm')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -918,11 +964,12 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 7, expectedVersion: plan.version })
|
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 7, expectedVersion: plan.version })
|
||||||
expect(popup.props('modelValue')).toBe(true)
|
expect(popup.props('modelValue')).toBe(true)
|
||||||
expect(input.props('modelValue')).toBe(7)
|
expect(input.props('modelValue')).toBe(7)
|
||||||
expect(popup.text()).toContain('加入当前柜次失败')
|
expect(assignmentDialog().text()).toContain('加入当前柜次失败')
|
||||||
expect(popup.text()).toContain('当前账号无权访问该数据')
|
expect(assignmentDialog().text()).toContain('当前账号无权访问该数据')
|
||||||
expect(popup.text()).toContain('assign-request')
|
expect(assignmentDialog().text()).toContain('assign-request')
|
||||||
expect(popup.text()).toContain('assign-trace')
|
expect(assignmentDialog().text()).toContain('assign-trace')
|
||||||
expect(wrapper.get('.global-loading-page').findAll('.load-error-state')).toHaveLength(1)
|
expect(assignmentDialog().findAll('.load-error-state')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.global-loading-page').findAll('.load-error-state')).toHaveLength(0)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -839,7 +839,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
<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">
|
<LoadErrorState v-if="mutationError" title="加入当前柜次失败" :message="mutationError.message" compact :show-retry="false">
|
||||||
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
@@ -852,7 +852,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
</section>
|
</section>
|
||||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||||
<section class="density-dialog-section" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form class="density-form">
|
<el-form class="density-form">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
|
<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-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||||
@@ -867,13 +867,11 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</section>
|
</section>
|
||||||
<template v-if="selectedCandidate.coLoadGroupId">
|
<template v-if="selectedCandidate.coLoadGroupId">
|
||||||
<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>
|
<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 :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -882,6 +880,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.smart-loading-assignment-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||||||
.assignment-context-table { table-layout: fixed; }
|
.assignment-context-table { table-layout: fixed; }
|
||||||
.assignment-context-table th { width: 120px; }
|
.assignment-context-table th { width: 120px; }
|
||||||
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { AxiosError } from 'axios'
|
|||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||||||
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
|
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
vi.mock('../api/platformContainerLoading', () => ({
|
vi.mock('../api/platformContainerLoading', () => ({
|
||||||
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
||||||
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
|
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
|
||||||
@@ -233,6 +235,11 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
const dialog = wrapper.getComponent(AppDialog)
|
const dialog = wrapper.getComponent(AppDialog)
|
||||||
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
||||||
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
||||||
|
const label = dialog.get('label[for="cabinet-loading-assignment-count"]')
|
||||||
|
expect(label.element.parentElement?.tagName).toBe('TH')
|
||||||
|
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
|
||||||
|
expect(dialog.get('input#cabinet-loading-assignment-count').attributes('aria-label')).toBe('本次计划装柜箱数')
|
||||||
|
expect(count.attributes('aria-required')).toBe('true')
|
||||||
expect(count.props('max')).toBe(12)
|
expect(count.props('max')).toBe(12)
|
||||||
count.vm.$emit('update:modelValue', 5)
|
count.vm.$emit('update:modelValue', 5)
|
||||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||||||
@@ -256,7 +263,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(wrapper.text()).toContain('DP-260908-0001')
|
expect(wrapper.text()).toContain('DP-260908-0001')
|
||||||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
expect(document.body.textContent).not.toContain('不可拆分单元整托加入')
|
||||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||||
const dialog = wrapper.getComponent(AppDialog)
|
const dialog = wrapper.getComponent(AppDialog)
|
||||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||||
|
|||||||
@@ -180,14 +180,6 @@ function startQuantityEdit(source: object) {
|
|||||||
quantityDrafts[item.id] = item.packageCount
|
quantityDrafts[item.id] = item.packageCount
|
||||||
editingQuantityId.value = item.id
|
editingQuantityId.value = item.id
|
||||||
}
|
}
|
||||||
function explainCandidate(source: object) {
|
|
||||||
const candidate = candidateRow(source)
|
|
||||||
const volume = Number(candidate.averageUnitVolumeCbm || 0)
|
|
||||||
const weight = Number(candidate.unitGrossWeightKg || 0)
|
|
||||||
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
|
|
||||||
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
|
|
||||||
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
|
|
||||||
}
|
|
||||||
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
|
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
|
||||||
if (!value) return '待生成'
|
if (!value) return '待生成'
|
||||||
const cabinetNo = String(value.cabinetNo || '').trim()
|
const cabinetNo = String(value.cabinetNo || '').trim()
|
||||||
@@ -419,7 +411,7 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
|
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :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="density-dialog-sections">
|
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||||
<section class="density-dialog-section" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form class="density-form">
|
<el-form class="density-form">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
|
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
|
||||||
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
|
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
|
||||||
@@ -427,22 +419,18 @@ onMounted(load)
|
|||||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
|
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
|
||||||
<tr><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td><td colspan="2"><el-form-item label="本次计划装柜箱数" label-position="left" label-width="140px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
<tr><th>可用箱数</th><td colspan="3">{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||||
|
<tr><th><label for="cabinet-loading-assignment-count"><span class="assignment-required" aria-hidden="true">*</span>本次计划装柜箱数</label></th><td colspan="3"><el-form-item><el-input-number id="cabinet-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>
|
</template>
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</section>
|
</section>
|
||||||
<template v-if="selectedCandidate.coLoadGroupId">
|
<template v-if="selectedCandidate.coLoadGroupId">
|
||||||
<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>
|
<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 :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
|
|
||||||
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
@@ -451,6 +439,9 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.assignment-table { table-layout: fixed; }
|
||||||
|
.assignment-table th { width: 128px; }
|
||||||
|
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
||||||
.workbench { display: grid; gap: var(--admin-space-5); min-width: 0; }
|
.workbench { display: grid; gap: var(--admin-space-5); min-width: 0; }
|
||||||
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
||||||
.workbench-command-bar { justify-content: space-between; gap: var(--admin-space-5); }
|
.workbench-command-bar { justify-content: space-between; gap: var(--admin-space-5); }
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerWorkbenchActualFacts {
|
export interface ContainerWorkbenchActualFacts {
|
||||||
|
arrivedDestinationPortAt?: string | null
|
||||||
actualSailingAt?: string | null
|
actualSailingAt?: string | null
|
||||||
actualPortArrivalAt?: string | null
|
actualPortArrivalAt?: string | null
|
||||||
actualWarehouseArrivalAt?: string | null
|
actualWarehouseArrivalAt?: string | null
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { projectWarehouseArrival } from './warehouseArrival'
|
||||||
|
|
||||||
|
describe('shared warehouse arrival projection', () => {
|
||||||
|
it.each([
|
||||||
|
['2026-10-21T15:49:00', '2026-10-23'],
|
||||||
|
['2026-10-30T23:30:00-07:00', '2026-11-01'],
|
||||||
|
['2026-12-31T23:30:00', '2027-01-02'],
|
||||||
|
['2026-03-07T23:30:00-08:00', '2026-03-09'],
|
||||||
|
])('displays the authoritative local date for %s without manufacturing a time', (arrivalAt, date) => {
|
||||||
|
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 0, plannedDate: date, isDefault: true })).toEqual({ date, time: '', isDefault: true })
|
||||||
|
})
|
||||||
|
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||||
|
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||||
|
expect(projectWarehouseArrival(plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||||
|
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||||
|
expect(projectWarehouseArrival(plan).date).toBeNull()
|
||||||
|
}
|
||||||
|
expect(projectWarehouseArrival(undefined).date).toBeNull()
|
||||||
|
})
|
||||||
|
it('keeps the frozen server date without recalculating after a port date changes', () => {
|
||||||
|
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02', isDefault: false }))
|
||||||
|
.toEqual({ date: '2026-11-02', time: '', isDefault: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface WarehouseArrivalPlan {
|
||||||
|
plannedAt: string | null
|
||||||
|
version: number
|
||||||
|
planCount: number
|
||||||
|
plannedDate?: string | null
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅展示服务端统一解析的日期,不在清关列表或日历重复推算。 */
|
||||||
|
export function projectWarehouseArrival(plan: WarehouseArrivalPlan | undefined) {
|
||||||
|
const date = plan && plan.version >= 0 ? plan.plannedAt?.slice(0, 10) || plan.plannedDate || null : null
|
||||||
|
return { date, time: date ? plan?.plannedAt?.slice(11, 16) || '' : '', isDefault: !!date && plan?.isDefault === true }
|
||||||
|
}
|
||||||
@@ -59,8 +59,10 @@
|
|||||||
|
|
||||||
|
|
||||||
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
||||||
|
.customer-editor-dialog .density-form--outlined { --el-border-radius-base: var(--cc-radius-lg); }
|
||||||
|
.customer-editor-dialog .density-form--outlined .el-input-number .el-input__inner { text-align: left; }
|
||||||
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
||||||
.customer-editor-dialog .customer-editor-table td { width: 50%; }
|
.customer-editor-dialog .customer-editor-table td:not([colspan]) { width: calc(100% / 3); }
|
||||||
.customer-review-table th { width: 120px; }
|
.customer-review-table th { width: 120px; }
|
||||||
.customer-review-documents th:first-child { width: 150px; }
|
.customer-review-documents th:first-child { width: 150px; }
|
||||||
.customer-review-documents th:last-child { width: 150px; }
|
.customer-review-documents th:last-child { width: 150px; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
@@ -31,6 +32,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
|||||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||||
import CustomersView from './CustomersView.vue'
|
import CustomersView from './CustomersView.vue'
|
||||||
|
|
||||||
|
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||||
|
|
||||||
const customersSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/pages/CustomersView.vue'), 'utf8')
|
const customersSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/pages/CustomersView.vue'), 'utf8')
|
||||||
|
|
||||||
const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({
|
const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({
|
||||||
@@ -179,7 +182,7 @@ describe('CustomersView', () => {
|
|||||||
it('searches the customer list by customer name only', () => {
|
it('searches the customer list by customer name only', () => {
|
||||||
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
|
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
|
||||||
expect(customersSource).toContain("label: '客户昵称'")
|
expect(customersSource).toContain("label: '客户昵称'")
|
||||||
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
|
expect(customersSource).toContain('placeholder="选填,默认自动生成"')
|
||||||
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
||||||
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
||||||
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
||||||
@@ -194,6 +197,32 @@ describe('CustomersView', () => {
|
|||||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps compact customer fields in three columns and leaves wide fields and validation readable', async () => {
|
||||||
|
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
tableButtonByText('编辑')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
const tables = Array.from(document.querySelectorAll<HTMLTableElement>('.customer-editor-dialog .customer-editor-table'))
|
||||||
|
expect(tables).toHaveLength(2)
|
||||||
|
expect(Array.from(tables[0]!.rows, row => row.cells.length)).toEqual([3, 3, 1, 1])
|
||||||
|
expect(Array.from(tables[1]!.rows, row => row.cells.length)).toEqual([3, 3, 1])
|
||||||
|
expect(tables.flatMap(table => Array.from(table.querySelectorAll('td[colspan]'), cell => cell.getAttribute('colspan')))).toEqual(['3', '3', '3'])
|
||||||
|
expect(document.querySelector<HTMLElement>('.customer-editor-dialog .qualification-credit-code .el-form-item__label')?.style.width).toBe('144px')
|
||||||
|
const company = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入工商登记公司名称"]')!
|
||||||
|
expect(company.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('120px')
|
||||||
|
const legalName = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入营业执照上的企业名称"]')!
|
||||||
|
expect(legalName.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('144px')
|
||||||
|
expect(company.closest('form')?.classList.contains('density-form--outlined')).toBe(true)
|
||||||
|
company.value = ''
|
||||||
|
company.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
company.dispatchEvent(new FocusEvent('blur'))
|
||||||
|
await vi.waitFor(() => expect(company.closest('.el-form-item')?.querySelector('.el-form-item__error')?.textContent).toContain('请输入公司名称'))
|
||||||
|
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).toContain('保存资料和资质')
|
||||||
|
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).not.toContain('保存资料时会一并上传已选资质')
|
||||||
|
expect(updatePlatformTenant).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
|
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
|
||||||
expect(customersSource).toContain('class="qualification-validity" role="cell"')
|
expect(customersSource).toContain('class="qualification-validity" role="cell"')
|
||||||
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
|
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
|
||||||
|
|||||||
@@ -1065,7 +1065,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<ListPageLayout aria-label="客户管理">
|
<ListPageLayout aria-label="客户管理">
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
|
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
|
||||||
<template #toolbar-start><h1>客户列表</h1></template>
|
<template #toolbar-start><h1>客户列表</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
||||||
<template #notices><template v-if="loadError">
|
<template #notices><template v-if="loadError">
|
||||||
@@ -1085,28 +1085,26 @@ onMounted(() => { void loadCustomers() })
|
|||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
||||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-form--outlined density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="选填,默认自动生成" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" label-width="144px" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
<tr><td colspan="3"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||||
<tr><td colspan="2"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
<tr><td colspan="3"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
||||||
<tr><td colspan="2"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
|
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
<tr><td colspan="3"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||||
<tr><td colspan="2"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></h3>
|
||||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small v-if="!creditCodeEstablished">必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。</small></el-form-item></td></tr></tbody></table>
|
||||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||||
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
||||||
@@ -1126,7 +1124,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
<template #footer><div class="customer-editor-footer"><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||||
@@ -1185,13 +1183,12 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
<AppDialog v-model="passwordDialogOpen" title="重置客户密码" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
|
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
|
||||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||||
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
|
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
|
||||||
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
|
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
|
||||||
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
@@ -1221,9 +1218,9 @@ onMounted(() => { void loadCustomers() })
|
|||||||
.customer-company-name { display:block; overflow:hidden; color:var(--workbench-text); font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
|
.customer-company-name { display:block; overflow:hidden; color:var(--workbench-text); font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
|
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
|
||||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
||||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
.qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||||
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); line-height:1.5; }
|
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); line-height:1.5; }
|
||||||
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--workbench-surface-soft); color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--cc-list-table-header-bg); color:var(--cc-list-table-header-text); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||||
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--workbench-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
|
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--workbench-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
|
||||||
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--workbench-border); border-radius:6px; background:var(--workbench-surface); color:var(--workbench-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--workbench-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--workbench-border); border-radius:6px; background:var(--workbench-surface); color:var(--workbench-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--workbench-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
||||||
@@ -1242,7 +1239,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
||||||
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--workbench-surface); }
|
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--workbench-surface); }
|
||||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
|
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
|
||||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
|
.customer-editor-footer { display:flex; align-items:center; justify-content:flex-end; gap:var(--admin-space-6, 16px); }
|
||||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--workbench-surface-soft); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); }
|
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--workbench-surface-soft); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); }
|
||||||
.review-document-state { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
.review-document-state { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
||||||
.review-document-error { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
.review-document-error { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ onMounted(() => { void load() })
|
|||||||
<div class="business-page overseas-fees-page admin-density customer-density">
|
<div class="business-page overseas-fees-page admin-density customer-density">
|
||||||
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||||
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
|
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
||||||
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
|
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
|
||||||
@@ -93,9 +93,9 @@ onMounted(() => { void load() })
|
|||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||||
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||||
<div class="customer-fee-tier-groups">
|
<div class="customer-fee-tier-groups">
|
||||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
|
||||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||||
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
|||||||
|
|
||||||
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
|
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
|
||||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><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" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
|
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-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" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
|
||||||
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
|
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
|
||||||
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
|
||||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
|
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
|
||||||
<template #toolbar-start><h1>下载中心</h1></template>
|
<template #toolbar-start><h1>下载中心</h1></template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
|
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section aria-live="polite"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
<section aria-live="polite"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="pagedRows"
|
:data="pagedRows"
|
||||||
@@ -427,8 +427,9 @@ onMounted(() => {
|
|||||||
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||||
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
||||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
|
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
|
||||||
.upload-file-control { display: flex; align-items: center; gap: var(--admin-space-5); }
|
.upload-file-control { display: flex; width: 100%; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||||
.upload-file-name { min-width: 0; overflow-wrap: anywhere; }
|
.upload-file-control > :deep(.el-button) { flex: 0 0 auto; }
|
||||||
|
.upload-file-name { min-width: 0; flex: 1 1 180px; overflow-wrap: anywhere; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.table-actions :deep(.el-button) { min-height: 40px; }
|
.table-actions :deep(.el-button) { min-height: 40px; }
|
||||||
.upload-file-control { align-items: stretch; flex-direction: column; }
|
.upload-file-control { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ describe('FeeItemsView public master data and access', () => {
|
|||||||
expect(actions).not.toMatch(/<(?:el-dropdown|Pencil|Power|Eye|Trash2|Download|CircleX)\b/)
|
expect(actions).not.toMatch(/<(?:el-dropdown|Pencil|Power|Eye|Trash2|Download|CircleX)\b/)
|
||||||
})
|
})
|
||||||
it('keeps the prototype table treatment and mobile form fallback without inventing a pricing mode', () => {
|
it('keeps the prototype table treatment and mobile form fallback without inventing a pricing mode', () => {
|
||||||
expect(source).toContain('appearance="integrated" :stripe="false"')
|
expect(source).toContain('appearance="integrated"')
|
||||||
|
expect(source).not.toContain(':stripe="false"')
|
||||||
|
expect(source).not.toContain(':stripe="false"')
|
||||||
expect(source).toContain(':page-sizes="[20, 50, 100]"')
|
expect(source).toContain(':page-sizes="[20, 50, 100]"')
|
||||||
expect(source).toContain('@media (max-width: 720px)')
|
expect(source).toContain('@media (max-width: 720px)')
|
||||||
expect(source).toContain('.fee-item-editor-table tr { display: grid; }')
|
expect(source).toContain('.fee-item-editor-table tr { display: grid; }')
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ onBeforeUnmount(clearVoucherPreview)
|
|||||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
|
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||||
<template #toolbar-start><h1>客户账户</h1></template>
|
<template #toolbar-start><h1>客户账户</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
|
||||||
<template #notices><template v-if="stateError"><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
|
<template #notices><template v-if="stateError"><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
|||||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||||
<template #toolbar-start><h1>客户应收应付</h1></template>
|
<template #toolbar-start><h1>客户应收应付</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
||||||
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
|
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
|
||||||
|
|||||||
@@ -680,7 +680,7 @@ watch(detailRouteId, (statementId) => {
|
|||||||
<ListPageLayout v-else aria-label="客户对账单">
|
<ListPageLayout v-else aria-label="客户对账单">
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
|
||||||
<template #toolbar-start><h1>客户对账单</h1></template>
|
<template #toolbar-start><h1>客户对账单</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
|
||||||
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
|
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
|
||||||
@@ -733,7 +733,7 @@ watch(detailRouteId, (statementId) => {
|
|||||||
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
|
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
|
||||||
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
|
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
|
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
|
||||||
<div class="workbench-table"><el-table :data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
|
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
|
||||||
</div></section>
|
</div></section>
|
||||||
</el-form>
|
</el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -606,7 +606,7 @@ onMounted(() => {
|
|||||||
<ListPageLayout v-else :aria-label="title">
|
<ListPageLayout v-else :aria-label="title">
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
||||||
<template #toolbar-start><h1>{{ title }}</h1></template>
|
<template #toolbar-start><h1>{{ title }}</h1></template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<QueryToolbar v-model="filters" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
|
<QueryToolbar v-model="filters" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe('finance presentation governance', () => {
|
|||||||
it('keeps bordered statement details and lets the adjustment editor scroll with its dialog', () => {
|
it('keeps bordered statement details and lets the adjustment editor scroll with its dialog', () => {
|
||||||
expect(statements).toContain(':data="detail.lines" size="small" border stripe')
|
expect(statements).toContain(':data="detail.lines" size="small" border stripe')
|
||||||
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
|
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
|
||||||
expect(statements).toContain(':data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"')
|
expect(statements).toContain(':data="adjustLines" size="small" border stripe row-key="statementLineId"')
|
||||||
expect(statements).not.toContain('max-height="420"')
|
expect(statements).not.toContain('max-height="420"')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ onMounted(() => { void loadCustomers(); void load() })
|
|||||||
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
|
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
|
||||||
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
|
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
|
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
|
||||||
<template #toolbar-start><h1>应收款统计表</h1></template>
|
<template #toolbar-start><h1>应收款统计表</h1></template>
|
||||||
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
|
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
|
||||||
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
|
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ describe('finance status presentation', () => {
|
|||||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||||
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
|
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
|
||||||
|
expect(financeOperationActionLabel('FEE_SOURCE_PUBLISHED')).toBe('费用同步')
|
||||||
|
expect(financeOperationActionLabel('FEE_SOURCE_VERSION_ADJUST')).toBe('费用调整同步')
|
||||||
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
|
||||||
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
|
||||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const financeOperationActionLabels: Record<string, string> = {
|
|||||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
|
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
|
||||||
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
|
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
|
||||||
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
|
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
|
||||||
|
FEE_SOURCE_PUBLISHED: '费用同步', FEE_SOURCE_VERSION_ADJUST: '费用调整同步',
|
||||||
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
|
||||||
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
|
||||||
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ onMounted(loadInitialData)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
||||||
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
||||||
<template #toolbar-start><h1>用户管理</h1></template>
|
<template #toolbar-start><h1>用户管理</h1></template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ onMounted(loadPermissions)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||||
<template #toolbar-start><h1>菜单权限</h1></template>
|
<template #toolbar-start><h1>菜单权限</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
||||||
<template #notices><template v-if="loadError">
|
<template #notices><template v-if="loadError">
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ describe('RolesView', () => {
|
|||||||
expect(rolesSource).toContain("(row as Role).status || '—'")
|
expect(rolesSource).toContain("(row as Role).status || '—'")
|
||||||
})
|
})
|
||||||
it('uses bordered striped tables and semantic role operation buttons', () => {
|
it('uses bordered striped tables and semantic role operation buttons', () => {
|
||||||
expect(rolesSource).toContain('appearance="integrated" :stripe="false"')
|
expect(rolesSource).toContain('appearance="integrated"')
|
||||||
|
expect(rolesSource).not.toContain(':stripe="false"')
|
||||||
|
expect(rolesSource).not.toContain(':stripe="false"')
|
||||||
expect(rolesSource).toContain('size="small" data-tone="neutral"')
|
expect(rolesSource).toContain('size="small" data-tone="neutral"')
|
||||||
expect(rolesSource).toContain('size="small" :data-tone=')
|
expect(rolesSource).toContain('size="small" :data-tone=')
|
||||||
expect(rolesSource).toContain('size="small" data-tone="danger"')
|
expect(rolesSource).toContain('size="small" data-tone="danger"')
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ onMounted(loadRoles)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
|
<section 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="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
|
||||||
<template #toolbar-start><h1>角色权限</h1></template>
|
<template #toolbar-start><h1>角色权限</h1></template>
|
||||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
|
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
|
||||||
<template #notices><template v-if="loadError">
|
<template #notices><template v-if="loadError">
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section :aria-busy="loading"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
<section :aria-busy="loading"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="result.records"
|
:data="result.records"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('InboundMeasurementDialog', () => {
|
|||||||
expect(source).toContain('class="inbound-measurement-workspace-dialog admin-density-dialog admin-density-workspace inbound-density admin-workbench is-dialog"')
|
expect(source).toContain('class="inbound-measurement-workspace-dialog admin-density-dialog admin-density-workspace inbound-density admin-workbench is-dialog"')
|
||||||
expect(source).toContain('width="min(1560px, calc(100vw - 48px))"')
|
expect(source).toContain('width="min(1560px, calc(100vw - 48px))"')
|
||||||
expect(source).toContain('height: 92vh')
|
expect(source).toContain('height: 92vh')
|
||||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__header')
|
expect(densitySource).toContain('.admin-density-dialog.el-dialog > .el-dialog__header')
|
||||||
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
|
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
|
||||||
expect(source).toContain('<WeightOcrPanel')
|
expect(source).toContain('<WeightOcrPanel')
|
||||||
expect(source).toContain('<InboundMeasurementPanel')
|
expect(source).toContain('<InboundMeasurementPanel')
|
||||||
|
|||||||
@@ -143,19 +143,19 @@ function handleSubmitted() {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__body { flex: 1 1 auto; }
|
.inbound-measurement-workspace-dialog > .el-dialog__body { flex: 1 1 auto; }
|
||||||
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
|
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
|
||||||
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
||||||
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
|
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
|
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
|
.inbound-measurement-workspace-dialog > .el-dialog__header { padding: 16px; }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 14px; }
|
.inbound-measurement-workspace-dialog > .el-dialog__body { padding: 14px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 14px 12px; }
|
.inbound-measurement-workspace-dialog > .el-dialog__header { padding: 14px 12px; }
|
||||||
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 10px; }
|
.inbound-measurement-workspace-dialog > .el-dialog__body { padding: 10px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -187,15 +187,29 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678')
|
expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678')
|
||||||
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
|
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
|
||||||
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
|
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
|
||||||
await wrapper.find('.el-table__expand-icon').trigger('click')
|
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.text()).toContain('箱规周长')
|
|
||||||
expect(wrapper.text()).toContain('190 cm')
|
|
||||||
expect(wrapper.text()).toContain('MJ20210327')
|
|
||||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
|
||||||
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps server over-limit warnings visible without declaration deviations or an expandable row', async () => {
|
||||||
|
const detail = measurement()
|
||||||
|
detail.items[0]!.overLimitWarning = '箱规超过渠道限制,请复核'
|
||||||
|
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
|
||||||
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
|
expect(wrapper.find('.measurement-deviation-alert').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||||
|
expect(wrapper.get('.measurement-table .measurement-limit-warning').text()).toBe('箱规超过渠道限制,请复核')
|
||||||
|
expect(wrapper.get('.measurement-table .measurement-limit-warning').attributes('role')).toBe('alert')
|
||||||
|
|
||||||
|
detail.items[0]!.overLimitWarning = null
|
||||||
|
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
|
||||||
|
await wrapper.setProps({ inboundId: '7002' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.measurement-limit-warning').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('.measurement-table th').some(cell => cell.text() === '超限提醒')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
|
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
|
||||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
|
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
|
||||||
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
|
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
|
||||||
@@ -407,20 +421,14 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
|
it('retains server-calculated facts in the draft without the removed expandable row', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
await wrapper.get('.el-table__expand-icon').trigger('click')
|
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
|
||||||
await flushPromises()
|
expect(wrapper.vm.form.items[0]).toMatchObject({
|
||||||
|
boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL',
|
||||||
const text = wrapper.text()
|
calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30,
|
||||||
expect(text).toContain('箱规')
|
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
|
||||||
expect(text).toContain('50×40×30cm')
|
})
|
||||||
expect(text).toContain('计算方式')
|
|
||||||
expect(text).toContain('实测原尺寸')
|
|
||||||
expect(text).toContain('计算后外箱')
|
|
||||||
expect(text).toContain('50 × 40 × 30 cm')
|
|
||||||
expect(text).toContain('计算规则版本')
|
|
||||||
expect(text).toContain('DIMENSION_ACTUAL_V1')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||||
@@ -611,11 +619,9 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
expect(wrapper.emitted('updated')).toBeUndefined()
|
expect(wrapper.emitted('updated')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps the formal deviceMeasuredAt response field into the read-only measurement time', async () => {
|
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
await wrapper.get('.el-table__expand-icon').trigger('click')
|
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10:30:00+08:00')
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
|
||||||
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
||||||
expect(source).toContain('measuredAt: item.deviceMeasuredAt')
|
expect(source).toContain('measuredAt: item.deviceMeasuredAt')
|
||||||
expect(source).not.toContain('measuredAt: item.measuredAt')
|
expect(source).not.toContain('measuredAt: item.measuredAt')
|
||||||
|
|||||||
@@ -614,10 +614,6 @@ function updateWeightEvidence(
|
|||||||
confirmedWeightAvailable.value = hasConfirmedWeight
|
confirmedWeightAvailable.value = hasConfirmedWeight
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
|
|
||||||
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
|
|
||||||
}
|
|
||||||
|
|
||||||
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
||||||
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
|
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
|
||||||
}
|
}
|
||||||
@@ -789,23 +785,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
class="measurement-table"
|
class="measurement-table"
|
||||||
:row-class-name="measurementRowClassName"
|
:row-class-name="measurementRowClassName"
|
||||||
>
|
>
|
||||||
<el-table-column type="expand" width="42">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<dl class="measurement-row-details">
|
|
||||||
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
|
|
||||||
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
|
||||||
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
|
||||||
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
|
|
||||||
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
|
|
||||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³</dd></div>
|
|
||||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
|
||||||
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
|
||||||
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
|
|
||||||
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
|
|
||||||
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="itemNo" label="#" width="70" align="center" />
|
<el-table-column prop="itemNo" label="#" width="70" align="center" />
|
||||||
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
|
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
|
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
|
||||||
@@ -822,6 +801,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
|
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
|
||||||
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="form.items.some(item => item.overLimitWarning?.trim())" label="超限提醒" min-width="200">
|
||||||
|
<template #default="{ row }"><span v-if="row.overLimitWarning?.trim()" class="measurement-limit-warning" role="alert">{{ row.overLimitWarning }}</span><span v-else>—</span></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
|
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
|
||||||
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -928,9 +910,9 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
|
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
|
||||||
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||||
.measurement-comparison thead th { background: #f8fafc; }
|
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
|
||||||
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
|
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
|
||||||
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
|
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); color: #18795d; }
|
||||||
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
||||||
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||||
.measurement-extra-facts strong { color: var(--workbench-text); }
|
.measurement-extra-facts strong { color: var(--workbench-text); }
|
||||||
@@ -984,16 +966,14 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
.manual-entry-upload { width: 100%; }
|
.manual-entry-upload { width: 100%; }
|
||||||
.measurement-items-section { background: var(--admin-surface, #fff); }
|
.measurement-items-section { background: var(--admin-surface, #fff); }
|
||||||
.measurement-table { width: 100%; font-size: var(--admin-font-caption, 12px); }
|
.measurement-table { width: 100%; font-size: var(--admin-font-caption, 12px); }
|
||||||
.measurement-table :deep(.el-table__header th) { padding: var(--admin-space-3, 8px) 0; background: #f6f8fa; color: #475467; font-size: var(--admin-font-caption, 11px); font-weight: 700; }
|
.measurement-table :deep(.el-table__header th) { padding: var(--admin-space-3, 8px) 0; background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-size: var(--admin-font-caption, 11px); font-weight: 700; }
|
||||||
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
|
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
|
||||||
.measurement-table :deep(.cell) { line-height: 1.35; }
|
.measurement-table :deep(.cell) { line-height: 1.35; }
|
||||||
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
|
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
|
||||||
.warehouse-suggestion-table { display: grid; gap: var(--admin-space-4, 10px); margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); }
|
.warehouse-suggestion-table { display: grid; gap: var(--admin-space-4, 10px); margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); }
|
||||||
.measurement-box-code { font-weight: 650; }
|
.measurement-box-code { font-weight: 650; }
|
||||||
|
.measurement-limit-warning { white-space: normal; overflow-wrap: anywhere; color: var(--el-color-danger-dark-2); }
|
||||||
.measurement-count-editor { width: 104px !important; }
|
.measurement-count-editor { width: 104px !important; }
|
||||||
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) 20px; margin: 0; padding: var(--admin-space-1, 4px) 18px var(--admin-space-3, 8px) 62px; }
|
|
||||||
.measurement-row-details dt { color: var(--workbench-muted); font-size: var(--admin-font-caption, 11px); }
|
|
||||||
.measurement-row-details dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
|
|
||||||
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-5, 12px) 20px; border-top: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-5, 12px) 20px; border-top: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
|
||||||
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: var(--admin-space-2, 6px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: var(--admin-space-2, 6px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
|
||||||
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
||||||
@@ -1001,7 +981,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -1015,7 +994,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
|||||||
.manual-entry-table colgroup { display: none; }
|
.manual-entry-table colgroup { display: none; }
|
||||||
.manual-entry-table tr { display: grid; grid-template-columns: 36% minmax(0, 1fr); }
|
.manual-entry-table tr { display: grid; grid-template-columns: 36% minmax(0, 1fr); }
|
||||||
.manual-entry-table :is(th, td) { min-width: 0; }
|
.manual-entry-table :is(th, td) { min-width: 0; }
|
||||||
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/* Measurement workspace: keep all sections inside the panel's content bounds. */
|
/* Measurement workspace: keep all sections inside the panel's content bounds. */
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||||
|
|
||||||
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
||||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ vi.mock('./api', () => ({
|
|||||||
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
|
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
name: 'WeightOcrPanel',
|
name: 'WeightOcrPanel',
|
||||||
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean },
|
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean },
|
||||||
emits: ['changed'],
|
emits: ['changed'],
|
||||||
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
|
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
|
||||||
},
|
},
|
||||||
@@ -193,6 +193,7 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
|
|
||||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||||
|
expect(weightPanel.props('tableMode')).toBe(true)
|
||||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
||||||
|
|
||||||
<section class="review-data density-dialog-section" data-tone="summary" aria-labelledby="review-data-title">
|
<section class="review-data density-dialog-section" aria-labelledby="review-data-title">
|
||||||
<header class="review-section-heading density-dialog-section__title"><h3 id="review-data-title">审核数据总览</h3></header>
|
<header class="review-section-heading density-dialog-section__title"><h3 id="review-data-title">审核数据总览</h3></header>
|
||||||
<div class="workbench-evidence"><table class="review-comparison density-table" aria-label="客户申报与平台实测对比">
|
<div class="workbench-evidence"><table class="review-comparison density-table" aria-label="客户申报与平台实测对比">
|
||||||
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
|
||||||
@@ -286,6 +286,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
|||||||
|
|
||||||
<WeightOcrPanel
|
<WeightOcrPanel
|
||||||
review-mode
|
review-mode
|
||||||
|
table-mode
|
||||||
:correction-mode="correctionMode"
|
:correction-mode="correctionMode"
|
||||||
:customer-id="customerId"
|
:customer-id="customerId"
|
||||||
:inbound-id="inboundId"
|
:inbound-id="inboundId"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../api/weightOcr'
|
} from '../api/weightOcr'
|
||||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||||
import WeightOcrPanel from './WeightOcrPanel.vue'
|
import WeightOcrPanel from './WeightOcrPanel.vue'
|
||||||
|
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
|
||||||
|
|
||||||
vi.mock('../api/weightOcr', async () => {
|
vi.mock('../api/weightOcr', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
||||||
@@ -151,6 +152,46 @@ function dialogConfirmButton(text = '确认重量') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('WeightOcrPanel confirmation behavior', () => {
|
describe('WeightOcrPanel confirmation behavior', () => {
|
||||||
|
it('keeps the weight confirmation outside its measurement parent with independent fullscreen and cancel state', async () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'token'
|
||||||
|
auth.permissions = allWeightPermissions
|
||||||
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
|
const wrapper = mount(InboundMeasurementDialog, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { modelValue: true, customerId: '10001', inboundId: '20001', inboundCode: 'RK-20001' },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, ElementPlus],
|
||||||
|
stubs: { InboundMeasurementPanel: { template: '<section class="measurement-panel" />' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
const panel = wrapper.getComponent(WeightOcrPanel)
|
||||||
|
await openWeightDialog(panel)
|
||||||
|
const popup = panel.getComponent(AppDialog)
|
||||||
|
const parent = document.querySelector('.inbound-measurement-workspace-dialog')!
|
||||||
|
const child = document.querySelector('input[aria-label="当前图片确认重量"]')!.closest('.el-dialog')!
|
||||||
|
expect(document.body.contains(child)).toBe(true)
|
||||||
|
expect(parent.contains(child)).toBe(false)
|
||||||
|
expect(child.classList.contains('admin-workbench')).toBe(true)
|
||||||
|
await dialogWeightInput().setValue('286.125')
|
||||||
|
await new DOMWrapper(child).get('button[aria-label="展开全屏"]').trigger('click')
|
||||||
|
expect(child.classList.contains('is-fullscreen')).toBe(true)
|
||||||
|
expect(parent.classList.contains('is-fullscreen')).toBe(false)
|
||||||
|
expect(dialogWeightInput().element.value).toBe('286.125')
|
||||||
|
await new DOMWrapper(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(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
await openWeightDialog(panel)
|
||||||
|
expect(popup.props('modelValue')).toBe(true)
|
||||||
|
expect(child.classList.contains('is-fullscreen')).toBe(false)
|
||||||
|
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
@@ -455,7 +496,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(wrapper.text()).toContain('1747.500 KG')
|
expect(wrapper.text()).toContain('1747.500 KG')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('atomically corrects and confirms an approved weight during measurement review', async () => {
|
it('opens a table image and atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||||
const confirmed = {
|
const confirmed = {
|
||||||
...pendingWeight,
|
...pendingWeight,
|
||||||
recognitionStatus: 'CONFIRMED',
|
recognitionStatus: 'CONFIRMED',
|
||||||
@@ -473,13 +514,14 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
weightResults: [confirmed],
|
weightResults: [confirmed],
|
||||||
})
|
})
|
||||||
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
|
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
|
||||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true })
|
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||||
expect(wrapper.text()).not.toContain('删除图片')
|
expect(wrapper.text()).not.toContain('删除图片')
|
||||||
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
|
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
|
||||||
await flushPromises()
|
expect(wrapper.find('.weight-grid').exists()).toBe(false)
|
||||||
|
await openWeightDialog(wrapper)
|
||||||
await dialogWeightInput().setValue('125')
|
await dialogWeightInput().setValue('125')
|
||||||
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
||||||
expect(reason).not.toBeNull()
|
expect(reason).not.toBeNull()
|
||||||
|
|||||||
@@ -658,6 +658,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
:confirm-text="previewConfirmText"
|
:confirm-text="previewConfirmText"
|
||||||
confirm-type="success"
|
confirm-type="success"
|
||||||
width="min(1080px, calc(100vw - 28px))"
|
width="min(1080px, calc(100vw - 28px))"
|
||||||
|
append-to-body
|
||||||
fullscreenable
|
fullscreenable
|
||||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||||
>
|
>
|
||||||
@@ -743,7 +744,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
|
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
|
||||||
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
|
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
|
||||||
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
|
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
|
||||||
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface-soft); color: var(--workbench-muted); font-weight: 700; white-space: nowrap; }
|
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-weight: 700; white-space: nowrap; }
|
||||||
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; vertical-align: middle; white-space: nowrap; }
|
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; vertical-align: middle; white-space: nowrap; }
|
||||||
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid var(--workbench-border); }
|
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid var(--workbench-border); }
|
||||||
.weight-data-table tbody tr:nth-child(even) td { background: var(--workbench-surface-soft); }
|
.weight-data-table tbody tr:nth-child(even) td { background: var(--workbench-surface-soft); }
|
||||||
|
|||||||
@@ -410,9 +410,9 @@ onMounted(() => void load())
|
|||||||
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
|
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
|
||||||
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
||||||
</template>
|
</template>
|
||||||
<section class="density-dialog-section" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
|
<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="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="110px" label="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||||
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item></td></tr>
|
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item></td></tr>
|
||||||
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
|
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
|
||||||
@@ -425,7 +425,7 @@ onMounted(() => void load())
|
|||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
||||||
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
|
<div class="workbench-evidence"><table class="density-table allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
|
||||||
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
|
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
|
||||||
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table></div>
|
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table></div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||||
import type { LoadingDetail } from '../api/platformLoadingLists'
|
import type { LoadingDetail } from '../api/platformLoadingLists'
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
describe('shared manifest presentation', () => {
|
describe('shared manifest presentation', () => {
|
||||||
|
it('names producer events and retains separate adjustments even when their visible facts match', async () => {
|
||||||
|
const actions = ['CREATE_PLAN', 'ADD_INBOUND', 'ADJUST_ITEM', 'ADJUST_ITEM', 'REMARKS', 'UPDATE_PRELOAD_TRANSPORT']
|
||||||
|
const wrapper = mount(LoadingListDetailDrawer, {
|
||||||
|
props: { mode: 'confirmed', canEdit: false, loading: false, error: null,
|
||||||
|
detail: {
|
||||||
|
summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [],
|
||||||
|
histories: actions.map((actionType, index) => ({
|
||||||
|
historyId: String(index + 1), operationId: `operation-${index + 1}`, itemId: null, actionType,
|
||||||
|
fromStatus: 'EDITING', toStatus: 'EDITING', beforeSnapshot: null, afterSnapshot: null,
|
||||||
|
operatorId: '7', operatorName: '操作员', operationReason: null, occurredAt: '2026-10-21T10:00:00',
|
||||||
|
})),
|
||||||
|
} as unknown as LoadingDetail },
|
||||||
|
global: { plugins: [ElementPlus], directives: { loading: {} }, stubs: { DataTable: true } },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
const rows = wrapper.findAll('.history-section .el-table__body tbody tr')
|
||||||
|
expect(rows).toHaveLength(6)
|
||||||
|
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual([
|
||||||
|
'创建计划', '加入入仓货物', '调整箱数', '调整箱数', '修改备注', '修改预装运输信息',
|
||||||
|
])
|
||||||
|
expect(wrapper.get('.history-section__toggle').text()).toContain('6 条')
|
||||||
|
expect(wrapper.get('.history-section').text()).not.toContain('状态更新')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
|
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
|
||||||
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||||
const wrapper = shallowMount(LoadingListDetailDrawer, {
|
const wrapper = shallowMount(LoadingListDetailDrawer, {
|
||||||
|
|||||||
@@ -145,8 +145,16 @@ function actionLabel(action: string) {
|
|||||||
return ({
|
return ({
|
||||||
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
||||||
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
||||||
CONFIRM_PRELOAD: '确认预装',
|
CREATE_PLAN: '创建计划', ADD_ITEM: '加入货物', ADD_INBOUND: '加入入仓货物',
|
||||||
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
ADD_PENDING_REVIEW_ORDER: '加入待审核订单', ADD_PALLET: '加入封托托盘',
|
||||||
|
ADD_COLOAD_GROUP: '加入同柜组', REMOVE_COLOAD_GROUP: '移除同柜组',
|
||||||
|
SMART_RECOMMEND: '智能排柜', ADJUST_ITEM: '调整箱数', REMARKS: '修改备注',
|
||||||
|
REMOVE_ITEM: '移除货物', REMOVE_PALLET: '移除封托托盘', REMOVE_UNPICKED: '移除未拣货物',
|
||||||
|
UPDATE_PRELOAD_TRANSPORT: '修改预装运输信息', CONFIRM_PRELOAD: '确认预装',
|
||||||
|
START_LOADING: '开始拣货', PDA_COMPLETE_LOADING: 'PDA完成装柜', COMPLETE: '完成装柜',
|
||||||
|
UPLOAD_DELIVERY_ORDER: '上传DO资料',
|
||||||
|
ATTACH_DESTINATION_CLEARANCE: '关联目的港清关资料', UPLOAD_DESTINATION_CLEARANCE: '上传目的港清关资料',
|
||||||
|
COMPLETE_DESTINATION_CLEARANCE: '确认目的港清关完成',
|
||||||
} as Record<string, string>)[action] || '状态更新'
|
} as Record<string, string>)[action] || '状态更新'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ describe('LoadingListsView', () => {
|
|||||||
expect(source).toContain('@media (max-width: 720px)')
|
expect(source).toContain('@media (max-width: 720px)')
|
||||||
expect(source).toContain('.row-actions { gap: 4px; }')
|
expect(source).toContain('.row-actions { gap: 4px; }')
|
||||||
expect(source).toContain('row-actions density-actions admin-text-actions')
|
expect(source).toContain('row-actions density-actions admin-text-actions')
|
||||||
expect(source).toContain('appearance="integrated" :stripe="false"')
|
expect(source).toContain('appearance="integrated"')
|
||||||
|
expect(source).not.toContain(':stripe="false"')
|
||||||
|
expect(source).not.toContain(':stripe="false"')
|
||||||
expect(detailSource).toMatch(/:columns="itemColumns"\s+stripe/)
|
expect(detailSource).toMatch(/:columns="itemColumns"\s+stripe/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="canList && !error"
|
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="canList && !error"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ describe('预装清单统一弹窗', () => {
|
|||||||
const wrapper = await render(initial)
|
const wrapper = await render(initial)
|
||||||
const drawer = wrapper.getComponent({ name: 'LoadingListDetailDrawer' })
|
const drawer = wrapper.getComponent({ name: 'LoadingListDetailDrawer' })
|
||||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||||
expect(dialog(wrapper, '确认预装').props('modelValue')).toBe(false)
|
expect(dialog(wrapper, '推送PDA').props('modelValue')).toBe(false)
|
||||||
wrapper.getComponent(ElSelect).vm.$emit('update:modelValue', '71')
|
wrapper.getComponent(ElSelect).vm.$emit('update:modelValue', '71')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||||
const popup = dialog(wrapper, '确认预装')
|
const popup = dialog(wrapper, '推送PDA')
|
||||||
expect(popup.get('table[aria-label="确认预装资料"]').text()).toContain('操作员甲')
|
expect(popup.get('table[aria-label="确认预装资料"]').text()).toContain('操作员甲')
|
||||||
vi.mocked(confirmPreloadPlan).mockResolvedValue({ planId: initial.summary.planId, containerBookingId: initial.summary.containerBookingId, confirmationStatus: 'CONFIRMED', version: 8, readiness: { ...initial.summary.readiness, confirmationStatus: 'CONFIRMED', confirmedPreloadCount: 1, pdaTaskId: '91', pdaTaskStatus: 'PENDING', canConfirm: false, readyForPda: true } })
|
vi.mocked(confirmPreloadPlan).mockResolvedValue({ planId: initial.summary.planId, containerBookingId: initial.summary.containerBookingId, confirmationStatus: 'CONFIRMED', version: 8, readiness: { ...initial.summary.readiness, confirmationStatus: 'CONFIRMED', confirmedPreloadCount: 1, pdaTaskId: '91', pdaTaskStatus: 'PENDING', canConfirm: false, readyForPda: true } })
|
||||||
popup.vm.$emit('confirm'); await flushPromises()
|
popup.vm.$emit('confirm'); await flushPromises()
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
|||||||
expect(source).toContain('summary?.readiness.canConfirm')
|
expect(source).toContain('summary?.readiness.canConfirm')
|
||||||
expect(source).toContain('confirmSaving.value')
|
expect(source).toContain('confirmSaving.value')
|
||||||
expect(source).toContain('await loadDetail()')
|
expect(source).toContain('await loadDetail()')
|
||||||
expect(source).toContain('本操作将确认整份预装计划')
|
expect(source).toContain("preloadDialogTitle('推送PDA')")
|
||||||
|
expect(source).not.toContain('本操作将确认整份预装计划')
|
||||||
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
|
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
|
||||||
expect(source).not.toContain('/inbounds/')
|
expect(source).not.toContain('/inbounds/')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -297,9 +297,9 @@ watch([planId, warehouseId], () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle(editDialogTitle)" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle(editDialogTitle)" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||||
<section class="density-dialog-section" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
|
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="运输信息填写"><tbody>
|
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="运输信息填写"><tbody>
|
||||||
<tr><td><el-form-item label-position="left" label-width="120px" label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="120px" label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label-position="left" label-width="120px" label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="120px" label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label-position="left" label-width="120px" :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="120px" :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item></td></tr>
|
||||||
@@ -310,9 +310,9 @@ watch([planId, warehouseId], () => {
|
|||||||
|
|
||||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||||
|
|
||||||
<section class="density-dialog-section" data-tone="work">
|
<section class="density-dialog-section">
|
||||||
<el-form class="density-form">
|
<el-form class="density-form">
|
||||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
|
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||||
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||||
@@ -323,10 +323,9 @@ watch([planId, warehouseId], () => {
|
|||||||
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('确认预装并推送 PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('推送PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||||
<div class="confirm-content">
|
<div class="confirm-content">
|
||||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
<section class="density-dialog-section">
|
||||||
<section class="density-dialog-section" data-tone="summary">
|
|
||||||
<div class="workbench-evidence"><table class="density-table preload-confirm-table" aria-label="确认预装资料"><tbody>
|
<div class="workbench-evidence"><table class="density-table preload-confirm-table" aria-label="确认预装资料"><tbody>
|
||||||
<template v-if="detail"><tr><th>预装单号</th><td>{{ detail.summary.preloadNo }}</td></tr><tr><th>柜次号</th><td>{{ detail.summary.cabinetNo || '—' }}</td></tr><tr><th>集装箱号</th><td>{{ detail.summary.containerNo || '待补录' }}</td></tr></template>
|
<template v-if="detail"><tr><th>预装单号</th><td>{{ detail.summary.preloadNo }}</td></tr><tr><th>柜次号</th><td>{{ detail.summary.cabinetNo || '—' }}</td></tr><tr><th>集装箱号</th><td>{{ detail.summary.containerNo || '待补录' }}</td></tr></template>
|
||||||
<tr><th>PDA 操作员</th><td>{{ selectedAssignee?.displayName || '未选择' }}</td></tr>
|
<tr><th>PDA 操作员</th><td>{{ selectedAssignee?.displayName || '未选择' }}</td></tr>
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:data="rows"
|
:data="rows"
|
||||||
|
|||||||
@@ -127,6 +127,18 @@ describe('notification native dialogs and drawers', () => {
|
|||||||
await click(wrapper, '确认定时发送')
|
await click(wrapper, '确认定时发送')
|
||||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '9223372036854775100', recipientType: 'TENANT_ALL', variables: { order_no: 'NYEXX2610200001' }, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) }))
|
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '9223372036854775100', recipientType: 'TENANT_ALL', variables: { order_no: 'NYEXX2610200001' }, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) }))
|
||||||
})
|
})
|
||||||
|
it('keeps long template variable labels above their editable values', async () => {
|
||||||
|
const variable = 'customer_delivery_appointment_reference_number'
|
||||||
|
const wrapper = await composer([variable])
|
||||||
|
const field = wrapper.get('.message-variable-field')
|
||||||
|
expect(field.classes()).toContain('el-form-item--label-top')
|
||||||
|
expect(field.get('label').text()).toContain(variable)
|
||||||
|
expect((field.get('label').element as HTMLElement).style.width).not.toBe('100px')
|
||||||
|
await field.get('input[placeholder="请输入变量值"]').setValue('REFERENCE-20261021')
|
||||||
|
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
|
||||||
|
await click(wrapper, '立即发送')
|
||||||
|
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ variables: { [variable]: 'REFERENCE-20261021' } }))
|
||||||
|
})
|
||||||
it('locks composer close paths and retains the draft after a rejected send', async () => {
|
it('locks composer close paths and retains the draft after a rejected send', async () => {
|
||||||
const pending = deferred<NotificationTask>()
|
const pending = deferred<NotificationTask>()
|
||||||
vi.mocked(api.createNotification).mockReturnValue(pending.promise)
|
vi.mocked(api.createNotification).mockReturnValue(pending.promise)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function submit() {
|
|||||||
</section>
|
</section>
|
||||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="模板变量">
|
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="模板变量">
|
||||||
<h3 class="density-dialog-section__title">模板变量</h3>
|
<h3 class="density-dialog-section__title">模板变量</h3>
|
||||||
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
|
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" label-position="top" label-width="auto" class="message-variable-field" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
|
||||||
</section>
|
</section>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
|
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
|
||||||
@@ -90,6 +90,7 @@ async function submit() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.message-variable-field :deep(.el-form-item__label) { max-width: 100%; overflow-wrap: anywhere; }
|
||||||
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: var(--admin-space-3); }
|
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: var(--admin-space-3); }
|
||||||
.schedule-row :deep(.el-date-editor) { width: 100%; }
|
.schedule-row :deep(.el-date-editor) { width: 100%; }
|
||||||
@media(max-width:560px) { .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
|
@media(max-width:560px) { .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user