Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bfce44988 | ||
|
|
00b1fefecb | ||
|
|
88632e6e4f | ||
|
|
01ecd63991 | ||
|
|
3598310237 | ||
|
|
6b3286fa5a | ||
|
|
f155976f52 | ||
|
|
db32f31cc2 | ||
|
|
489b1bcd16 | ||
|
|
291535e242 | ||
|
|
1e80470078 | ||
|
|
6f7f8e7632 | ||
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c | ||
|
|
f4224e5d3a | ||
|
|
3267679758 | ||
|
|
7ff7c9914f | ||
|
|
5c28cd7c79 | ||
|
|
d2f2ecf57a | ||
|
|
5a46198bf0 | ||
|
|
ada663128a | ||
|
|
17cb1373e1 | ||
|
|
29f887496a | ||
|
|
b92e5379bc | ||
|
|
767c6b7589 | ||
|
|
f7f6637c45 | ||
|
|
05cf82dbbc | ||
|
|
8ae1e5d0d4 | ||
|
|
4bb21641d9 | ||
|
|
67d917010d | ||
|
|
a5a1976924 | ||
|
|
4806f88d7c | ||
|
|
2705fdbc49 | ||
|
|
ce7f6cee02 | ||
|
|
fbee53c9de | ||
|
|
45f662ecf2 | ||
|
|
148085286d | ||
|
|
f6638ca6c6 | ||
|
|
baee81f459 | ||
|
|
9f51392754 | ||
|
|
8dc0d2d3b8 | ||
|
|
538970797d |
@@ -36,61 +36,54 @@
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||
|
||||
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
|
||||
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||
| 消息中心 | `modules/notifications/AGENTS.md` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
|
||||
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
|
||||
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
|
||||
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
|
||||
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
|
||||
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
|
||||
|
||||
## 业务、安全与共享装配
|
||||
|
||||
@@ -127,7 +120,7 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Admin业务详细索引
|
||||
|
||||
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
|
||||
|
||||
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
|
||||
|
||||
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
|
||||
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
|
||||
## 货柜工作台规则
|
||||
|
||||
- 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/`
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||
+3
-2
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
|
||||
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
|
||||
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
|
||||
})
|
||||
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
|
||||
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
|
||||
]))
|
||||
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
|
||||
})
|
||||
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||
@@ -31,16 +47,16 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -196,13 +212,15 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||
})
|
||||
|
||||
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
|
||||
it('shows the restored supplier entry under general only with its list permission', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||
|
||||
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
|
||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).map(item => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
|
||||
it('shows split container workbenches without a standalone smart-loading menu', () => {
|
||||
|
||||
@@ -134,8 +134,9 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
||||
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
@@ -147,6 +148,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -204,7 +206,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
|
||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
|
||||
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin 预约日历模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
|
||||
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
|
||||
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
|
||||
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
|
||||
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
|
||||
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
|
||||
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
|
||||
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
|
||||
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
|
||||
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
|
||||
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
|
||||
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
|
||||
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
|
||||
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -14,7 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true, CargoVerificationForm: true,
|
||||
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
|
||||
} } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||
@@ -34,6 +34,21 @@ it('submits two conservative allocations without fake confirmation times and ren
|
||||
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
|
||||
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
|
||||
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
|
||||
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
|
||||
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
|
||||
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
|
||||
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
@@ -71,3 +86,98 @@ it('loads a candidate context before merging and preserves both original version
|
||||
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
|
||||
const context = batchContext()
|
||||
const source = context.appointment.items[0]!
|
||||
context.appointment.items = [source]
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
|
||||
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
|
||||
context.sources = []; context.allowedActions = []
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
|
||||
forty.appointmentStatus = 'SUCCESS'
|
||||
forty.items = [
|
||||
{ ...source, packageCount: 40 },
|
||||
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
|
||||
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
|
||||
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
|
||||
]
|
||||
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
|
||||
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
|
||||
context.currentBatches = [forty, twenty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(2)
|
||||
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||
expect(destinations.text()).toContain('已取消,分配保留')
|
||||
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
|
||||
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
|
||||
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
|
||||
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
|
||||
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
|
||||
const context = batchContext()
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||
context.sources = []; context.allowedActions = []
|
||||
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
|
||||
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
|
||||
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
|
||||
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
|
||||
context.currentBatches = [unknown, empty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(1)
|
||||
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
|
||||
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
|
||||
})
|
||||
it('does not fall back to historical replacement links when current destinations are empty', async () => {
|
||||
const context = batchContext()
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
|
||||
context.sources = []; context.allowedActions = []; context.currentBatches = []
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(0)
|
||||
expect(destinations.text()).toContain('原快照不代表当前待办')
|
||||
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
|
||||
})
|
||||
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
|
||||
const context = batchContext(), originalItem = context.appointment.items[0]!
|
||||
context.appointment.items = [context.appointment.items[1]!]
|
||||
context.sources = [context.sources[1]!]
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
|
||||
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
|
||||
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
twenty.items = [{ ...originalItem, packageCount: 20 }]
|
||||
context.currentBatches = [context.appointment, forty, twenty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||
expect(destinations.text()).not.toContain('IN-UNRELATED')
|
||||
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
|
||||
const original = batchContext()
|
||||
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
|
||||
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
|
||||
current.appointment.plannedDate = '2026-10-22'
|
||||
original.currentBatches = [current.appointment]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('AP-CURRENT')
|
||||
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
|
||||
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
|
||||
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -20,16 +20,28 @@ const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerification
|
||||
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
|
||||
let candidateRequest = 0
|
||||
let initializedKey: string | null = null
|
||||
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
|
||||
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
|
||||
const currentDestinations = computed(() => {
|
||||
if (!context.value) return []
|
||||
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
|
||||
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
|
||||
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
|
||||
})
|
||||
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
|
||||
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
|
||||
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; ready.value = false; error.value = null
|
||||
try {
|
||||
context.value = await getAppointmentBatch(activeKey.value)
|
||||
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
|
||||
if (!preserve) {
|
||||
if (!preserve || initializedKey !== activeKey.value) {
|
||||
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
|
||||
baseline.value = JSON.stringify(form)
|
||||
initializedKey = activeKey.value
|
||||
}
|
||||
ready.value = true
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
|
||||
@@ -62,7 +74,7 @@ function sourceLabel(source: CargoVerificationSource) {
|
||||
}
|
||||
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
|
||||
async function verified() { verification.value = null; await load(); emit('updated') }
|
||||
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; result.value = null; other.value = null; mode.value = 'split'; await load() }
|
||||
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
|
||||
async function save() {
|
||||
if (!context.value || !canSubmit.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
@@ -92,7 +104,20 @@ onMounted(() => load())
|
||||
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
|
||||
<template v-else-if="context">
|
||||
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
|
||||
<template v-if="context.appointment.replacementAppointmentNos?.length"><p>该批次已被替代,当前只读。{{ context.appointment.repartitionReason }}</p><div v-for="(number, index) in context.appointment.replacementAppointmentNos" :key="number"><el-button @click="emit('appointment', number)">{{ number }}</el-button><el-button v-if="context.appointment.replacementBatchKeys?.[index]" @click="openBatch(context.appointment.replacementBatchKeys[index]!)">查看当前批次</el-button></div></template>
|
||||
<template v-if="hasCurrentDestinations">
|
||||
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
|
||||
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
|
||||
<section aria-label="当前货物去向">
|
||||
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
|
||||
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
|
||||
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
|
||||
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
|
||||
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
|
||||
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
|
||||
</article>
|
||||
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
|
||||
</section>
|
||||
</template>
|
||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
|
||||
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
import { getWorkDetail, type WorkAction } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
|
||||
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
|
||||
})
|
||||
async function open() {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
|
||||
const content = creationBody()
|
||||
await content.get('input[type="checkbox"]').setValue(true)
|
||||
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
|
||||
return dialog
|
||||
}
|
||||
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
|
||||
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
expect(dialog.props('width')).toBe('520px')
|
||||
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
|
||||
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
|
||||
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
|
||||
})
|
||||
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
await dialog.get('input').setValue('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('上次提交结果尚未确认')
|
||||
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
})
|
||||
it('keeps complete long cargo names accessible in the selectable table', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
|
||||
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
|
||||
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
|
||||
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
|
||||
const wrapper = await open(); const dialog = await create(wrapper)
|
||||
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
|
||||
expect(fields.props('continuous')).toBe(true)
|
||||
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
|
||||
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
|
||||
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(date.props('modelValue')).toBe('2026-10-25')
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
|
||||
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(creationBody().text()).toContain('AP-001')
|
||||
})
|
||||
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
|
||||
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
|
||||
const wrapper = await open(); await create(wrapper)
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
const receipt = creationBody().get('[aria-label="预约保存回执"]')
|
||||
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
|
||||
})
|
||||
it('does not open or request creation data without its original write permission', async () => {
|
||||
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
|
||||
})
|
||||
it('uses the same real continuous form through the original work detail deep link', async () => {
|
||||
routing.query.workItem = 'work-delivery-1'
|
||||
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
|
||||
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
|
||||
await content.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(content.text()).toContain('本次保存已完成')
|
||||
})
|
||||
@@ -1,120 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
|
||||
describe('appointment calendar interactions', () => {
|
||||
it('keeps creation beside the title and record actions beside data notices', () => {
|
||||
const wrapper = mount(AppointmentCalendarPanel, {
|
||||
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
|
||||
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
|
||||
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
|
||||
expect(actions.text()).toContain('未定时安排')
|
||||
expect(actions.text()).toContain('取消记录')
|
||||
expect(actions.get('summary').text()).toContain('数据提示')
|
||||
wrapper.unmount()
|
||||
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 },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
describe('four-type appointment calendar', () => {
|
||||
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
|
||||
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||
})
|
||||
const render = () => mount(AppointmentCalendarPanel, {
|
||||
props: { initialDate: '2026-09-24', canCreate: true, events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
|
||||
] },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
it('opens a readable appointment without exposing write actions', async () => {
|
||||
const wrapper = render()
|
||||
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
|
||||
await wrapper.setProps({events:[event]})
|
||||
const actions=wrapper.get('.detail-event footer')
|
||||
expect(actions.text()).toBe('查看预约')
|
||||
await actions.get('button').trigger('click')
|
||||
expect(wrapper.emitted('view')?.[0]).toEqual([event])
|
||||
expect(wrapper.emitted('edit')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides optional references by default and counts only appointments after enabling them', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
|
||||
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
|
||||
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
|
||||
] })
|
||||
it('filters locally without requesting another range', async () => {
|
||||
const wrapper = render([event('arrival'), event('dispatch')])
|
||||
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
|
||||
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
|
||||
expect(wrapper.text()).toContain('当天暂无安排')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('selects an already loaded date without requesting data again', async () => {
|
||||
it('offers creation in the header only when authorized', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
|
||||
await wrapper.setProps({ canCreate: true })
|
||||
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([[]])
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
|
||||
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
|
||||
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
|
||||
] })
|
||||
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
|
||||
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requests exactly one range for a view switch and navigates seven days', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
|
||||
})
|
||||
|
||||
it('keeps busy days to three previews in both views and exposes the full count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
|
||||
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
|
||||
title: `船舶 ${index}`, fields: [],
|
||||
})) })
|
||||
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
|
||||
for (const view of ['month', 'week']) {
|
||||
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
|
||||
expect(day.findAll('.event-chip')).toHaveLength(3)
|
||||
expect(day.attributes('title')).toContain('共 233 条安排')
|
||||
expect(day.get('.event-more').text()).toBe('还有 230 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
|
||||
}
|
||||
it('limits previews to three while retaining every event in the daily details', () => {
|
||||
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
|
||||
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
|
||||
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
|
||||
})
|
||||
|
||||
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
it('keeps undated records outside date cells and exposes them explicitly', async () => {
|
||||
const wrapper = render([{ ...event('warehouse'), date: null }])
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('.undated-button').trigger('click')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
|
||||
})
|
||||
|
||||
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
|
||||
it('retains business links and exposes editing only with authoritative eligibility', async () => {
|
||||
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
|
||||
const wrapper = render([row])
|
||||
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
|
||||
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('open')).toEqual([[row]])
|
||||
expect(wrapper.emitted('edit')).toEqual([[row]])
|
||||
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
|
||||
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
|
||||
})
|
||||
it('distinguishes loading and partial failure from a complete empty day', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [{
|
||||
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
|
||||
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
|
||||
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
|
||||
}] })
|
||||
const card = wrapper.get('.detail-event')
|
||||
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
|
||||
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
|
||||
expect(card.get('.inbound-codes').text()).toContain('20 个')
|
||||
expect(card.get('footer').text()).toContain('编辑')
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||
|
||||
export interface CalendarEvent {
|
||||
key: string
|
||||
date: string
|
||||
endAt?: string
|
||||
time: string
|
||||
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
inboundCodes?: Array<string | null | undefined>
|
||||
canEdit?: boolean
|
||||
canView?: boolean
|
||||
canDelete?: boolean
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
notices?: string[]
|
||||
events: CalendarEvent[]
|
||||
loading?: boolean
|
||||
incomplete?: boolean
|
||||
canCreate?: boolean
|
||||
initialDate?: string
|
||||
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
|
||||
const emit = defineEmits<{
|
||||
layers: [layers: { arrivals: boolean; trips: boolean }]
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
view: [event: CalendarEvent]
|
||||
delete: [event: CalendarEvent]
|
||||
open: [event: CalendarEvent]
|
||||
}>()
|
||||
const showArrivals = ref(false), showTrips = ref(false)
|
||||
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
|
||||
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date()))
|
||||
const mode = ref<CalendarMode>('month')
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
|
||||
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
const result = new Map<string, ScheduleEvent[]>()
|
||||
for (const event of visibleEvents.value) {
|
||||
for (const date of dates.value) {
|
||||
if (!occursOnDate(event.date, event.endAt, date)) continue
|
||||
const group = result.get(date) || []
|
||||
group.push(event)
|
||||
result.set(date, group)
|
||||
}
|
||||
const date = event.date || 'undated'
|
||||
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||||
}
|
||||
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
|
||||
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
|
||||
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
|
||||
const heading = computed(() => mode.value === 'month'
|
||||
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
|
||||
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
|
||||
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
|
||||
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date
|
||||
selectedDate.value = date; undated.value = false
|
||||
const after = calendarRange(date, mode.value)
|
||||
emit('select', date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next
|
||||
emit('range', calendarRange(selectedDate.value, next))
|
||||
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
defineExpose({ selectedDate, mode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
@@ -84,126 +43,76 @@ defineExpose({ selectedDate, mode })
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<div class="view-toggle" role="group" aria-label="预约视图">
|
||||
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
|
||||
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
|
||||
</div>
|
||||
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
|
||||
<slot name="toolbar-actions" />
|
||||
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
|
||||
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</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 v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
|
||||
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
|
||||
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
|
||||
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
|
||||
<span class="day-events">
|
||||
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
|
||||
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
|
||||
</span>
|
||||
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
|
||||
</span>
|
||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
|
||||
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ Number(date.slice(8)) }}</span>
|
||||
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><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> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</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>
|
||||
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
|
||||
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
|
||||
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
|
||||
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
|
||||
</footer>
|
||||
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
|
||||
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
|
||||
.calendar-notice-content p { margin: 0; line-height: 1.6; }
|
||||
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
|
||||
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
|
||||
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
|
||||
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.calendar-label > svg { color: #617ce9; }
|
||||
.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
|
||||
.legend { font-size: 12px; white-space: nowrap; }
|
||||
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
|
||||
.legend.delivery::before { background: #27a87a; }
|
||||
.legend.pickup::before { background: #c17a20; }
|
||||
.legend.trip::before { background: #8562b0; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
|
||||
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
|
||||
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
|
||||
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: 14px; white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
|
||||
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
|
||||
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
|
||||
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
|
||||
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { display: block; font-size: 12px; font-weight: 600; }
|
||||
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
|
||||
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
|
||||
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
|
||||
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
|
||||
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
|
||||
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
|
||||
.event-more { padding: 1px 6px; line-height: 16px; }
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { height: 220px; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
|
||||
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
|
||||
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
|
||||
.detail-event .wide-field { grid-column: 1 / -1; }
|
||||
.detail-event dt { font-size: 12px; color: #8491a5; }
|
||||
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -1,126 +1,159 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
|
||||
import { listArrivalCalendar } from './api'
|
||||
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true }))
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
|
||||
function render() {
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference'], template: '<div data-calendar />' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
|
||||
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
|
||||
ApiErrorMeta: true,
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
|
||||
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
} } })
|
||||
}
|
||||
describe('appointment work calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true
|
||||
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
|
||||
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
})
|
||||
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('预约工作日历')
|
||||
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
})
|
||||
describe('simplified appointment entry', () => {
|
||||
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(true)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
|
||||
expect(panel.props('canWrite')).toBe(true)
|
||||
vi.mocked(getCalendarSources).mockClear()
|
||||
panel.vm.$emit('created'); await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('sends an explicit safe return context with external business navigation', async () => {
|
||||
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
|
||||
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
|
||||
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
|
||||
routing.denied.add(permission)
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(false)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
|
||||
calendar.vm.$emit('anchor', 1); await flushPromises()
|
||||
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
|
||||
const calls = vi.mocked(getWorkCalendar).mock.calls.length
|
||||
calendar.vm.$emit('types', []); await flushPromises()
|
||||
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
|
||||
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows a failed source as an error, not zero work or all complete', async () => {
|
||||
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
|
||||
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
|
||||
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
|
||||
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
|
||||
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
|
||||
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
|
||||
expect(wrapper.text()).toContain('计划到仓读取失败')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a late response after the global scope changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
|
||||
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
|
||||
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少排车读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回原事项')
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
|
||||
it('ignores stale calendar responses when the date range changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
|
||||
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
|
||||
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('goes directly to the current container unfinished list after route confirmation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
|
||||
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
|
||||
expect(getWorkSummary).toHaveBeenCalledTimes(2)
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
|
||||
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the selected date and view in the source return link', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list')
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ 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: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||
expect(listTripCalendar).toHaveBeenCalledOnce()
|
||||
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', event); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
routing.canReadSource = false
|
||||
const denied = render(); await flushPromises()
|
||||
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
|
||||
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,213 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { isAxiosError } from 'axios'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
|
||||
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
|
||||
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
|
||||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
|
||||
const savedAnchor = stringQuery('workAnchor')
|
||||
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
|
||||
const anchor = ref(initialAnchor())
|
||||
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
|
||||
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
|
||||
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
|
||||
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
|
||||
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
|
||||
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
|
||||
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
|
||||
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
|
||||
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
|
||||
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
|
||||
const navigationError = ref('')
|
||||
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
|
||||
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
|
||||
const range = computed(() => calendarRange(anchor.value, mode.value))
|
||||
const heading = computed(() => {
|
||||
const value = scope.value
|
||||
if (!value) return ''
|
||||
if (value.kind === 'global') return viewLabels[value.view]
|
||||
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
|
||||
if (value.kind === 'container') return `${value.label} · 本柜待办`
|
||||
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
|
||||
return `查找:${value.keyword}`
|
||||
})
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
|
||||
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
|
||||
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
|
||||
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
|
||||
async function loadCalendar() {
|
||||
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
|
||||
if (!types.value.length) { calendarLoading.value = false; return }
|
||||
calendarLoading.value = true
|
||||
const creating = ref(false)
|
||||
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
function initialDate() {
|
||||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||
}
|
||||
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
async function load(next = range.value) {
|
||||
range.value = next
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
let result: CalendarSources | null = null
|
||||
try {
|
||||
result = await getCalendarSources(next, signal)
|
||||
if (request !== generation) return
|
||||
sources.value = result
|
||||
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||||
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||||
if (request !== generation) return
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||||
}
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||||
if (request === generation) warehouses.value = rows
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
|
||||
try {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||||
async function openSource(event: ScheduleEvent) {
|
||||
if (!event.sourcePath || !await canLeave()) return
|
||||
const context = { date: selectedDate.value, mode: mode.value }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
const result = await getWorkCalendar({ from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }, calendarController.signal)
|
||||
if (request === calendarRequest) calendar.value = result
|
||||
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
|
||||
finally { if (request === calendarRequest) calendarLoading.value = false }
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function loadSummary() {
|
||||
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
|
||||
try { const result = await getWorkSummary(summaryController.signal); if (request === summaryRequest) summary.value = result }
|
||||
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
|
||||
finally { if (request === summaryRequest) summaryLoading.value = false }
|
||||
async function rereadEditor() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row) return
|
||||
saving.value = true
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||||
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||||
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||||
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function itemQuery(): WorkItemsQuery | null {
|
||||
const value = scope.value
|
||||
if (!value || value.kind === 'arrivals') return null
|
||||
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
|
||||
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
|
||||
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
|
||||
if (value.kind === 'search') query.keyword = value.keyword
|
||||
return query
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value || unknownResult.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
|
||||
catch { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function loadItems() {
|
||||
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
|
||||
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
|
||||
itemsLoading.value = true
|
||||
try { const result = await getWorkItems(query, itemsController.signal); if (request === itemsRequest) items.value = result }
|
||||
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
|
||||
finally { if (request === itemsRequest) itemsLoading.value = false }
|
||||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||||
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
|
||||
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||||
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||||
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||||
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||||
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||||
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||||
origins.value = []
|
||||
initialAction.value = null
|
||||
}, { immediate: true })
|
||||
async function openWork(key: string, action?: WorkAction) {
|
||||
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||||
}
|
||||
async function loadArrivals() {
|
||||
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
|
||||
if (!reference.value) { referenceLoading.value = false; return }
|
||||
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
|
||||
referenceLoading.value = true
|
||||
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
|
||||
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
|
||||
finally { if (request === referenceRequest) referenceLoading.value = false }
|
||||
}
|
||||
async function setScope(value: Scope) {
|
||||
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||||
async function closeBusiness(done: () => void) {
|
||||
if (!await canLeave()) return
|
||||
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
|
||||
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||||
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||||
}
|
||||
async function openItem(key: string, action?: WorkAction) {
|
||||
if (!await canLeave()) return
|
||||
navigationError.value = ''
|
||||
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
|
||||
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
|
||||
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
|
||||
}
|
||||
async function back() {
|
||||
if (!await canLeave()) return
|
||||
const origin = origins.value.pop()
|
||||
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
|
||||
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
|
||||
if (!origin) await loadItems()
|
||||
}
|
||||
async function closePanel() {
|
||||
if (!await canLeave()) return
|
||||
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
|
||||
}
|
||||
async function filterCalendar(change: () => void) {
|
||||
if (!await canLeave()) return
|
||||
change()
|
||||
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
|
||||
await Promise.all([loadCalendar(), loadArrivals()])
|
||||
}
|
||||
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
|
||||
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
|
||||
async function changePage(next: number) { page.value = next; await loadItems() }
|
||||
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
|
||||
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
|
||||
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
|
||||
const container = result.item.containers[0]
|
||||
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
|
||||
} else { detailKey.value = origins.value.pop() || null; initialAction.value = null; sourceLink.value = null }
|
||||
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
|
||||
}
|
||||
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
|
||||
async function external(path: string) {
|
||||
if (!await canLeave()) return
|
||||
const value = scope.value
|
||||
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(path, context))
|
||||
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||||
}
|
||||
onMounted(() => {
|
||||
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
|
||||
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
|
||||
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
|
||||
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
|
||||
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
|
||||
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
|
||||
detailKey.value = stringQuery('workItem') || null
|
||||
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
|
||||
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
|
||||
})
|
||||
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
sourceLink.value = null; linkError.value = ''
|
||||
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
|
||||
if (sourceKey || orderId || customerId || destinationKey) {
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
|
||||
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
|
||||
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
|
||||
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
|
||||
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
|
||||
onMounted(() => { void load() })
|
||||
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||||
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density appointment-work" spacing="compact">
|
||||
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
|
||||
<nav class="work-inbox" aria-label="全局待办">
|
||||
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong>{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>不受日历筛选影响 · 数量可重叠</span>
|
||||
</nav>
|
||||
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
|
||||
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
|
||||
<p v-if="linkError" role="alert">{{ linkError }}</p>
|
||||
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
|
||||
<div class="work-layout" :class="{ 'has-panel': panelOpen }">
|
||||
<WorkCalendarPanel :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })" />
|
||||
<aside v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><button type="button" @click="closePanel">关闭 ×</button></div>
|
||||
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
|
||||
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
|
||||
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
|
||||
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
|
||||
<template v-else>
|
||||
<h3>{{ heading }}</h3><p class="scope-description">{{ scopeDescription }}</p>
|
||||
<template v-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
|
||||
<template v-else>
|
||||
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
|
||||
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||||
<p v-if="itemsLoading">正在读取明细…</p>
|
||||
<template v-else-if="items && !itemsError"><p>共 {{ items.total }} 项</p><article v-for="item in items.records" :key="item.workItemKey" class="work-row"><div class="work-row-action"><el-button v-if="item.nextAction" type="primary" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div><div class="work-row-content"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><p>{{ typeLabels[item.type] }} · {{ workStatus(item) }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}</p><p v-if="item.followupDate">跟进 {{ item.followupDate }}</p><p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p></div></article><p v-if="!items.records.length">当前范围没有工作事项。</p><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></template>
|
||||
</template>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<div v-if="editor" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||||
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-5); flex-wrap: wrap; }.work-heading h2 { margin: 0; }.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-inbox { padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); background: var(--admin-surface); }.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }.work-search input { width: 320px; }
|
||||
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); }.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.work-layout { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--admin-space-5); }.work-layout.has-panel { grid-template-columns: minmax(0, 1fr) minmax(420px, 490px); }.work-panel { border: 1px solid var(--cc-border); padding: var(--admin-space-5); background: var(--admin-surface); min-width: 0; overflow-wrap: anywhere; max-height: 900px; overflow-y: auto; }.panel-nav { display: flex; justify-content: space-between; gap: var(--admin-space-3); position: sticky; top: 0; background: var(--admin-surface); z-index: 1; }.panel-nav > button:last-child { margin-left: auto; }.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
|
||||
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--cc-border); }.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-3); }.work-row-content p { margin: var(--admin-space-2) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }.work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-text-color-primary); }.work-warning { color: var(--el-color-warning-dark-2); }.work-pagination, .bucket-buttons { display: flex; align-items: center; justify-content: center; gap: var(--admin-space-3); flex-wrap: wrap; margin-top: var(--admin-space-3); }.bucket-buttons { justify-content: flex-start; }.bucket-buttons button { font-size: var(--admin-font-caption); }
|
||||
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media(max-width: 1100px) { .work-layout.has-panel { grid-template-columns: minmax(0, 1fr); } }
|
||||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||
</style>
|
||||
|
||||
@@ -43,7 +43,7 @@ async function save() {
|
||||
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||
<el-form label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -87,7 +87,6 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
|
||||
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
const guards = new Set<LeaveGuard>()
|
||||
function render(canWrite = true) {
|
||||
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
|
||||
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
|
||||
stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
|
||||
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
|
||||
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
|
||||
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
|
||||
},
|
||||
} })
|
||||
}
|
||||
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
|
||||
})
|
||||
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
|
||||
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
expect(destination().props?.filterable).toBe(true)
|
||||
expect(listPlanningDestinations).not.toHaveBeenCalled()
|
||||
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
|
||||
open(true); await flushPromises()
|
||||
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
|
||||
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
|
||||
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
await selectAndPrepare(wrapper)
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
|
||||
expect(wrapper.text()).not.toContain(cargo.orderId)
|
||||
wrapper.unmount(); expect(guards.size).toBe(0)
|
||||
})
|
||||
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.get('[data-date]').setValue('2026-10-25')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||
expect(wrapper.find('[data-date]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').text()).toContain('等待对方确认')
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
|
||||
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('disables blocked cargo and keeps selected cargo across pages', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports source failures and does not offer a write path without permission', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a source resolution that arrives after the dialog is closed', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
|
||||
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
|
||||
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||||
import type { SourceReference } from './workApi'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
|
||||
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
|
||||
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
|
||||
let destinationsRequest = 0, destinationsController: AbortController | null = null
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
|
||||
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
|
||||
options: destinations.value.map(code => ({ label: code, value: code })),
|
||||
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
|
||||
if (visible && !destinationsLoaded.value) void loadDestinations()
|
||||
} } },
|
||||
])
|
||||
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
|
||||
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
|
||||
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
|
||||
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '箱数', width: 80 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||||
]
|
||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||
function reason(row: PlanningCargo) {
|
||||
if (row.detained) return '货物暂扣中'
|
||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||
if (row.hasActiveAppointment) return '已有有效预约'
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||
return ''
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: boolean | string | number) {
|
||||
if (!props.canWrite || resolving.value) return
|
||||
if (!checked) delete selected.value[key(row)]
|
||||
else if (!reason(row)) selected.value[key(row)] = row
|
||||
selectionError.value = null
|
||||
}
|
||||
async function load(nextPage = page.value) {
|
||||
if (!props.canWrite) return
|
||||
page.value = nextPage
|
||||
const current = ++generation; controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
|
||||
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
|
||||
if (current === generation) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
|
||||
} finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
keyword.value = String(query.value.keyword || '').trim()
|
||||
destination.value = String(query.value.destination || '').trim()
|
||||
void load(1)
|
||||
}
|
||||
async function loadDestinations() {
|
||||
if (!props.canWrite || destinationsLoading.value) return
|
||||
const current = ++destinationsRequest
|
||||
destinationsController?.abort(); destinationsController = new AbortController()
|
||||
destinationsLoading.value = true; destinationError.value = null
|
||||
try {
|
||||
const result = await listPlanningDestinations(destinationsController.signal)
|
||||
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
|
||||
} catch (cause) {
|
||||
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
|
||||
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
|
||||
}
|
||||
async function prepare() {
|
||||
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
|
||||
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
|
||||
resolving.value = true; selectionError.value = null
|
||||
try {
|
||||
const sources: SourceReference[] = []
|
||||
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
|
||||
for (const row of snapshot) {
|
||||
if (current !== selectionGeneration) return
|
||||
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
|
||||
if (current !== selectionGeneration) return
|
||||
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
|
||||
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
|
||||
}
|
||||
if (current === selectionGeneration) references.value = sources
|
||||
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
|
||||
finally { if (current === selectionGeneration) resolving.value = false }
|
||||
}
|
||||
async function back() { if (await props.canLeave()) references.value = null }
|
||||
function saved(appointment: FbaAppointmentDto) {
|
||||
created.value = appointment; references.value = null; selected.value = {}
|
||||
emit('created', appointment)
|
||||
}
|
||||
async function again() {
|
||||
if (!await props.canLeave()) return
|
||||
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
|
||||
await load()
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
|
||||
<template v-if="created">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
|
||||
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
|
||||
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
|
||||
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
|
||||
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||||
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
|
||||
</section>
|
||||
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
|
||||
</template>
|
||||
<template v-else-if="references">
|
||||
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
|
||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||||
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -105,10 +105,10 @@ onBeforeUnmount(() => { ++generation })
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||||
|
||||
@@ -58,7 +58,7 @@ onMounted(load)
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="canWrite && detail.impact.status === 'OPEN'">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
describe('calendar cache lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
})
|
||||
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
|
||||
const active = ref(true)
|
||||
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
|
||||
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
|
||||
active.value = false; await flushPromises()
|
||||
expect(signal.aborted).toBe(true)
|
||||
active.value = true; await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -53,7 +54,7 @@ onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货物核实资料">
|
||||
<h3>核实货物资料</h3><p>未约成也可保存;只用于预约核实,保留原订单资料。</p>
|
||||
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取货物资料…</p>
|
||||
<template v-if="context">
|
||||
|
||||
@@ -1,10 +1,207 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(v10)与预约工作日历开发协作契约 v1。下表取代旧三视图、整行选货和日历辅助方案;不改变原业务实际执行入口。
|
||||
## 2026-10-20 排车入口收敛
|
||||
|
||||
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
|
||||
|
||||
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|
||||
|---|---|---|---|
|
||||
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
|
||||
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
|
||||
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
|
||||
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
|
||||
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
|
||||
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
|
||||
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
|
||||
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
|
||||
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
|
||||
|
||||
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
|
||||
|
||||
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
|
||||
|
||||
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
|
||||
|
||||
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
|
||||
|
||||
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
|
||||
|
||||
2026-10-16 本次 UI 检查:用户重新要求检查后,运行 Admin 全量测试,397 文件 / 3292 项通过;发现并修正 QueryToolbar 必填 `ariaLabel` 属性的模板写法。补充真实 KeepAlive + Teleport 离开/恢复及点击提醒弹层回归后,最新定向检查 3 文件 / 66 项通过,最终 `pnpm typecheck`、`pnpm build` 和 `git diff --check` 通过。构建仍有既有 AnalyticsChart 约 588 kB 分块提示。此记录更新下文历史“未运行验证”的结论;本地自动检查不代表测试环境或人工视觉验收。所属客户显示缺项仍待后端合同补齐,INP 采集报错的脚本来源未确认,本次未部署。日志为 `.logs/appointment-ui-current-{test,focused,typecheck,build}.log`。
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
|
||||
2026-10-16 操作列样式核对:用户截图 `codex-clipboard-9d54345c-c9c1-4991-82ba-b319948f781b.png` 明确要求与外层表格统一。
|
||||
|
||||
| 页面 | 操作 | 截图依据与布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 预约工作明细表格 | 查看详情或服务端提供的下一步操作 | 操作列内蓝色普通文字,无边框、底色或装饰图标 | `RowActions` 默认描边样式 | 使用公共 `text-actions` 模式,保留当前文案、权限、点击承载和长文案换行;待人工截图确认 |
|
||||
|
||||
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
|
||||
|
||||
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
|
||||
|
||||
## V13 前端本轮最终收口(2026-10-16)
|
||||
|
||||
已完成 cargo-refresh 按客户最新资料预览/明确采用、60+40 原排车单共车续办,以及原车当前/历史稳定批次引用。当前合同与具体范围以下述对应章节为准,早期分批检查数字仅为过程记录。
|
||||
|
||||
- 最新完整受影响组合:41 文件、357 项通过(18.64 秒),覆盖全部 appointment-calendar、Fleet trips API/页面/安排/交接、排车预排/详情/列表及密度合同。
|
||||
- 全量前端 `pnpm test`:397 文件、3290 项通过(137.44 秒),无失败;存在既有 Vue const reactive v-model 编译提示,本批未扩展修改无关模块。
|
||||
- 最终 `pnpm typecheck` 通过;`pnpm build`(另含 vue-tsc)通过,Vite 16.24 秒,仅既有 AnalyticsChart 588.19 kB 分块警告。`git diff --check` 通过。
|
||||
- 本地日志:`.logs/appointment-v13-final-combined.log`、`appointment-v13-final-full-test.log`、`appointment-v13-final-typecheck.log`、`appointment-v13-final-build.log`;冻结文件与 SHA-256 清单为 `.logs/appointment-v13-frontend-freeze.json`。日志为本地证据,不提交。
|
||||
- 本轮前端冻结;没有提交、推送、部署、SQL 或真实 HTTP。人工 1920×1080、100% 截图与真实 60+40 装载、客户移出后重进、后送 40/20、cargo-refresh 持久事实仍待总控组织验收。后端客户间 FAILED/PENDING 排车资格隔离由总控收口,不改变本轮前端合同。
|
||||
|
||||
## V13 第一批实现与接口依赖
|
||||
|
||||
### 本批收货覆盖合同接入
|
||||
|
||||
已按生产者 `FbaReceivingContracts.java`、`FbaReceivingController.java`、`FbaReceivingService.java` 和 `appointment-receiving-v1.yaml` 接入 `receivingApi.ts`,浏览器前缀为 `/platform/v1/last-leg/appointment-receiving`(公共 HTTP 层统一提供 `/api`)。真实入口如下:
|
||||
|
||||
- GET `/batches/{batchKey}` 和 `/agreements/{agreementKey}`:读取批次、当前覆盖、待确认变化、车辆范围版本及重约 eligible/excluded。
|
||||
- POST `/changes` 和 `/changes/{changeKey}/resolve`:逐来源先送 0..当前量,持久 WAITING;明确回复 CONFIRM 或撤回 WITHDRAW。保留原批次/来源/核实/transport 版本,未知结果重试原幂等命令。
|
||||
- POST `/bookings`:一份共同 booking 或每成员独立 booking,沿用当前批次,不新建货物;取消组携带原 agreementKey/version,只提交 eligible 成员。
|
||||
- POST `/agreements/{agreementKey}/reschedule`、`/cancel`:展示整份当前范围,提交真实范围版本;取消需要 delete 权限及二次确认,普通编辑和客户移出分别遵循 Controller 的独立权限组合。
|
||||
|
||||
接入文件:`WorkReceivingPanel.vue`、只读 `ReceivingScope.vue`;入口为 `WorkAppointmentForm.vue` 的“客户调整 / 共同预约范围”,由 `WorkDetailPanel.vue` 在原居中弹框内装配并保留结果。Member.customerName、Source.inboundCode 已消费,无名无号显示“—”,不以内部标识回退。原批次快照未提供客户名时只能从已返回的覆盖成员匹配或显示“—”,不猜客户名称。
|
||||
|
||||
结果只报告服务端实际 steps;本地原子事务失败不包装成部分成功。待确认结果明确原安排未变;保存成功但刷新失败只重试 GET。范围变化后保留输入并重新确认;重约成员集合变化时禁止把原输入套给新成员,显式重新填写。已返回的真实车次可跳原详情核对,后送步骤按 ALLOCATE_CARGO.targetKey 匹配成功批次,不取任意第一批;无业务号不拼车辆链接。未知箱数不替换为 0 或加入可预约范围。
|
||||
|
||||
本批前端直接验证:`receivingApi.test.ts`、`WorkReceivingPanel.test.ts`、`WorkDetailPanel.test.ts`、`WorkAppointmentForm.test.ts` 共 4 文件、36 项通过,`pnpm typecheck` 通过。未重复全模块测试或全量构建。生产者已通知新源码编译通过,但本批后端回归和真实环境验收尚无通过结论;不能据前端模拟 HTTP 的定向测试宣称端到端通过。
|
||||
|
||||
后续闭合情况见下节:子批真实事项解析、原车按需客户卡片、原来源过滤及单批真实托盘预选均已接入;重约按最新 executionScope/executionVersion 判断客户资格;普通调整和整份操作继续使用 transportVersion。以下早期依赖表用于逐场景定位,新合同的实际实现与剩余缺口以本节及下节为准。
|
||||
|
||||
已消费的合同为 `workApi.ts`、`batchApi.ts`、`planningApi.ts` 及原预约/排车 API。本批不新增接口或推导数量。已接核心冻结的 `FbaAppointmentBatchContext.currentBatches`:服务端解析全部替代分支、按稳定批次去重,前端只显示与 originalSourceRefs 的 customerId/sourceKey/destinationKey 完全匹配的当前明细,合并后的其他来源不算入原范围。未知箱数不显示 0,零量不生成去向卡片,空结果不回退历史替代链接。`replacementBatchKeys` 只用于识别历史快照,不能据此推导当前 20/40 箱去向。取消保留分配后的续办通过原预约更新接口提交版本和幂等键,不整票重建或重新分配。当前前端 `splitBatch` 与表单一次拆两批;服务端普通 repartition 的 DTO/OpenAPI 已支持 1 至 20 个非空目标批次,普通多批能力不构成接口依赖。本轮 100/60、40/20 保持两批交互。先送 0、已确认且有车的客户移出、共约范围变更仍需专用命令,不能以普通分批代替;尚未冻结的共享范围命令继续等待生产者。
|
||||
|
||||
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|
||||
|---|---|---|
|
||||
| 事项列表统一表格 | 用户截图 `codex-clipboard-1bde1991-b226-4956-8530-6de44074f818.png` 明确要求统一表格或卡片,不能混排 | 所有日期/类型/全局待办列表统一使用 DataTable 与 RowActions,替代早期多行卡片排版。操作固定最左;事项、类型、状态、作业时间、时区、流程、地点、负责人、下次跟进、提醒分列,时间保留实际/确认/目标口径。列表弹框加宽,表头与分页由共享表格承载;保留每页 8 项、筛选和返回上下文。当前调整覆盖旧卡片与独立分页结论;未运行验证或部署,待人工截图确认 |
|
||||
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
|
||||
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
|
||||
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
|
||||
| 04 后送 40 新批、20 待排 | `AppointmentBatchForm.test.ts` 消费 `currentBatches` 的 40/20 全部分支;逐客户/来源/目的仓过滤,排除合并的其他货物;未知量不当 0 | 已接客户变化确认及真实批次续办;已接当前/历史排车全量批次引用,实际装载链待集成;实物拆托是原库存前置 |
|
||||
| 05 全部再分配后的旧入口 | `AppointmentBatchForm.test.ts` 只显示当前终点,不显示中间替代批次;零量无卡片,空结果不回退旧链接,原快照明确仅供追溯 | 服务端负责图遍历及稳定键去重;真实多次再分配与跨页返回待集成 |
|
||||
| 06 待回复先保存再续办 | `WorkAppointmentForm` 保留计划/确认分开;`WorkDetailPanel` 保存后留在当前项 | 单客户调整草稿、仓库回复范围、待确认变化、原车阻断条件 DTO/命令 |
|
||||
| 07 共约旧约号冲突 | 原预约错误继续保留表单;不按文本约号判断共约 | 生产者共约身份、当前覆盖成员、唯一性冲突合同;真实冲突验收待集成 |
|
||||
| 08 整份改期、无变化保存 | `WorkAppointmentForm.test.ts` 无变化及空白字符归一化后不重复提交;跨日/偏移原样保留 | 整份范围、成员版本、车辆影响与无变化后端幂等;不能仅凭前端禁用证明不重复复核 |
|
||||
| 09 整份取消后共同重约 | `WorkAppointmentForm.test.ts` 已接取消保留分配后的原批更新续办,可先保存日期未定/PENDING;保留历史、版本及幂等键,不再创建整票;取消不能重复提交,只读权限不能续办 | 整组共同/分别重约已接 receiving Book,消费 eligible/excluded;新关联及原车实际资格待后端/集成验收 |
|
||||
| 10 分别重约与排除已另约成员 | 不复制现有逐票新建接口实现假整组成功 | 当前组资格、排除原因、分别重约结果、冲突/部分失败续办及所有时段核对 |
|
||||
| 11 保留量为 0、原车无货 | 当前前端普通拆分保留两批均非空的交互约束,不绕过服务端业务资格 | “取消本次安排/全部移出”专用语义、无货原车权威处理结果;不能用空批次代替 |
|
||||
| 12 未定日期、换月、图层、分页 | `AppointmentCalendarView.test.ts` 全局范围不带日历条件,保存后留在当前详情、返回保持原分页;既有前置返回/外部路由上下文继续检查;`WorkDetailPanel.test.ts` 跟进日不作作业日 | 新去向与新范围的上下文等待对应稳定标识;人工截图与真实再进入待验 |
|
||||
| 13 部分失败、超时、并发 | `WorkDispatchForm.test.ts` 重试原草稿、不重锁已提交实物;`WorkDetailPanel.test.ts` 保存成功但下一步读取失败仅重试 GET、旧操作禁用;`WorkAppointmentForm.test.ts` 原批重约结果未知同键重试,明确冲突保留输入、读取新版本后显式再提交 | 多步骤逐项结果/可重试动作及范围版本合同;现有草稿恢复不代表跨域 V13 全部已完成 |
|
||||
| 14 权限、失败、时区、DST | 既有 work/form/presentation/API 直接测试保留;来源失败显示明确错误 | 新增合同对应权限/版本/持久幂等与真实时区场景待生产者和集成验证 |
|
||||
| 15 五类计数、真实车次与执行门槛 | `WorkCalendarPanel.test.ts` 五类聚合固定数量入口,服务端 2 批送仓/1 车原样展示;日格定高,不在浏览器重算车辆数;原实际执行入口保留 | 去重由后端保证;直送卸货、还空、共车发车资格及接手结果继续真实验收 |
|
||||
|
||||
当前定向检查(含首批与 currentBatches/原批重约新增 6 项):`pnpm test src/modules/appointment-calendar src/styles/adminDensity.test.ts`,29 文件、210 项通过;`pnpm typecheck` 通过。完整测试/构建窗口按开发协作契约交总控安排,本批未执行。以上是本地实现与检查,不表示部署或 15 项全链已验收;居中弹框及新增去向/续办内容仍待人工截图确认。
|
||||
|
||||
|
||||
### 60+40 两步共车入口
|
||||
|
||||
`WorkSharedTripForm.vue` 仅按明确排车业务号读取两单,复用原 Fleet 安排弹框。真实 60/40 数量从两单读取,不自行生成装载结论;已关联、异仓、重复、错误业务号均不进入安排。子弹框打开时保护父工作面板,成功导航服务端返回的车次。增量联通检查覆盖 WorkDispatchForm、WorkSharedTripForm、既有 TripPreparationDialog 与 trips API;完整本轮验证见下一节。
|
||||
|
||||
### 最新货物资料显式采用
|
||||
|
||||
已消费 RefreshCargo/REFRESH_CARGO:WorkReceivingPanel 按客户提供“核对并采用本客户最新货物资料”,只按该客户原来源读取既有 cargo-verification(预约及尾程列表读权限),展示原票、本批箱数、最新核实/有效/已分配量及货件号。读取不会保存核实资料或自动采用。源/核实版本、身份、目的地、起运仓、未知分配、已分配超量及来源阻断均检查;任何一票失败不提交部分范围。
|
||||
|
||||
明确确认后 POST `/appointment-receiving/cargo-refresh` 只携带该客户成员及显示的 batchVersion、executionVersion、各来源最新版本;不提交箱数或正式确认。已确认、待变化、已完成客户禁用。冲突后重读并清除确认,未知结果保持原请求键,成功刷新事项并可继续本客户计划/正式预约,不跳回会影响其他客户的旧整批 PUT。
|
||||
|
||||
本轮共车与资料采用最初组合为 8 文件、81 项通过;已被下述当前/历史引用收口后的完整组合覆盖。未执行 HTTP、部署或人工截图。
|
||||
|
||||
### 客户级普通计划补齐(V13 06 / 09 / 10)
|
||||
|
||||
- 已消费实际 `Context.plans`、`CustomerPlan` 和 `Plan`,通过 POST `/appointment-receiving/plans` 提交明确 batchKey/customerId/batchVersion、executionVersion 与幂等键。`WorkReceivingPanel` 客户卡片和本批重约范围提供“保存目标 / 未约成”;不走旧整批 PUT,不改变甲的确认或借分批保存日期。
|
||||
- 只允许 PENDING/FAILED,FAILED 必须有原因;普通计划无外部约号和正式确认时间。资格同时要求 PLAN_RECEIVING、当前执行资格、无活动确认和无待变化记录。时区来自该客户计划(未有计划时来自批次目的仓);非空目标缺时区阻止提交。计划窗口支持跨日及 startOffset/endOffset,DST 歧义或不存在时间由权威接口校验;只填日期或全空目标不带偏移。
|
||||
- 回显优先当前 batch/customer 的 plans,明确空日期保持空;无独立客户计划时,仅无任何有效独立覆盖且 header 为 PENDING/FAILED 才带入旧计划和原因,剔除正式确认。混合覆盖不借甲日期,CANCELLED 不回填旧目标。混合状态测试为同一稳定批次中甲已有独立确认、乙与丁未确认;覆盖乙保存日期、未约成、清空日期、丁从空白开始、只读/已完成/待变化禁用、版本冲突保留输入、未知结果同键重试、UNCHANGED、跨日/DST 和未知时区。保存后刷新当前工作事项,保留 receiving 面板及真实结果,原“记录回复与跟进”入口保留。
|
||||
- 整组取消后支持按客户勾选子集,共同或分别确认只提交所选成员。通过实际只读 POST `/appointment-receiving/execution-scope` 查询所选 batch/customer 的执行资格,使用返回版本,不复用全组或猜测 token;保留展示时的 batchVersion/groupVersion。选择变化清除范围确认,读失败禁止提交,可单独重读;过期异步响应不覆盖新选择。新增同批仅甲独立确认、跨批仅乙子集、分别表单与客户匹配、查询失败及迟到响应回归。
|
||||
- 直接测试:`WorkReceivingPanel.test.ts`、`receivingApi.test.ts`、`WorkDetailPanel.test.ts`、`TripAppointmentPanel.test.ts` 共 4 文件、56 项通过;`pnpm typecheck` 和最终 `pnpm build`(含 vue-tsc)通过,构建 4.78 秒;仅既有 AnalyticsChart 约 588 kB 分块警告。`git diff --check` 通过。本批前端冻结。未执行真实 HTTP、部署或人工截图验收;服务端甲确认不变、客户计划工作域投影及日历归属仍由总控集成核对。
|
||||
|
||||
### 本批续办、原车入口和历史来源收敛
|
||||
|
||||
- `batchApi.ts` 必需字段 `originalSourceRefs` 已与生产者 SourceRef/OpenAPI 同步;`AppointmentBatchForm` 与 `TripAppointmentPanel` 均按完整客户/来源/目的地过滤当前全部去向。原批仍保留货物但另有后送批时也显示当前去向。全客户移出后不再依赖原批当前 items,且不拿历史来源授权可分配数量;新增全移出、40/20、合并其他来源隔离回归。
|
||||
- `workApi.ts`、`WorkBatchContinuation.vue` 接真实客户级解析;保存原/后送预约后继续当前子批事项,原事项留作返回上下文,多客户不默认取第一项。
|
||||
- `TripDetailView.vue` 按需装配公开 `TripAppointmentPanel`:本车当前 appointmentBatches 与 previousDispatches 稳定引用→批次/currentBatches/receiving 范围;客户卡片列当前所有匹配去向,直接进入对应客户调整或当前批次续办。新弹框复用未保存/未知结果保护,刷新车次和路由离开也经过保护,业务提交后刷新本车事实。
|
||||
- `WorkDispatchForm.vue` 从单客户单批入口仅预选当前合格实物,候选跨批/客户、重复实物、未知箱数均不误选;提交仍复制服务端 allocations。可直接进入本批逐来源分批,60→40/20 测试只提交该 60 的原批和来源版本,不把原总量 160 再分配。多客户、超车型容量不自动全选;真实整托无法覆盖的部分箱量不伪造实物。
|
||||
- `WorkReceivingPanel.test.ts` 新增跨批 A 已另约、仅 B/D eligible 的重约回归:提交 B/D 的真实 batchKeys 与 GroupContext.executionScope.version(Book.executionVersion),不带回 A,也不使用原 A/B/D 范围版本或运输版本。待确认 WITHDRAW 仍可提交,不以普通货物可变性条件禁用撤回。
|
||||
|
||||
- 最新接收合同已消费:ChangeIntent.ownerId 由本人、当前事项及关联事项负责人候选选择,父事项传入候选,不新增用户目录或用自由姓名充当账号;Pending.followup 展示服务端 ownerName/nextFollowupDate。普通正式预约按 Context.executionScope 排除 completed 或 canRebook=false 的客户,整组按服务端 eligibleMembers;Book 只提交对应 executionVersion,运输展示或无关客户的运输阻断不再一刀切禁止重约。货物调整、确认/撤回和整份改期/取消仍使用 transportVersion。新增负责人候选/真实姓名、已完成 A 不挡未完成 B、缺失执行资格不擅自放行等回归。
|
||||
|
||||
当前合同接入、实物前置与真实验收边界:
|
||||
|
||||
1. 原车当前/历史引用已按冻结 DTO 接入:`DispatchResponse.appointmentBatches`、`TripBinding.appointmentBatches` 与 `Detail.previousDispatches` 均为必需数组,`batchNo` 允许 null,不依赖可空 `fbaAppointmentNo` 或内部排车 ID。`TripAppointmentPanel` 按批次/客户聚合并对排车业务号、来源和当前终点去重,精确匹配客户/来源/目的地,只展示 `currentBatches` 当前量。历史卡片只提供追溯和权威解析后的当前工作入口,不使用历史数量授权调整或排车。`appointmentHistoryUnavailableReason` 单独提示,缺乏证据的引用不开入口,当前车次仍可读。已补空原约号、多批引用、历史客户移出后重进、40/20 全去向、其他客户/来源隔离、重复记录、缺失历史提示和错误身份回归。真实持久历史及装载链待集成验收。
|
||||
2. 60+40 按真实两步流程收口:各客户按本批分配及实际托盘生成排车单,WorkDispatchForm 结果页进入 WorkSharedTripForm,按另一张业务号重读两单,核对当前状态、未绑定及真实起运仓一致后交给既有 TripPreparationDialog;Fleet prepare 复用多个 dispatchNos,可新建或加入原计划车次。未新增共车命令、未扫描全局待办、未按名称推仓。数量不能映射完整实物时必须先走原库存拆托,这属于实物前置,不列为待开发任意拆托;仍待真实 60+40 HTTP 和装载链验收。
|
||||
3. receiving 仍以当前源码接入,待核心与工作事项后端回归及真实场景。UI 本地通过不证明服务端初始化、同步及最终执行门槛已经部署验收。
|
||||
|
||||
验证:最新完整定向命令 `pnpm test src/modules/appointment-calendar src/modules/overseas-master-data/pages/TripsBehavior.test.ts src/styles/adminDensity.test.ts` 34 文件、260 项通过;`pnpm typecheck` 通过。`pnpm build`(含 vue-tsc)通过,Vite 构建 3.92 秒;既有 AnalyticsChart 约 588 kB 分块警告保留,无构建失败。`git diff --check` 通过;未提交、发布或部署,真实页面待人工截图确认。
|
||||
|
||||
### 下一批精确接入点与合同门槛
|
||||
|
||||
下表路径除特别标注外相对 `src/modules/appointment-calendar/`。新增语义仅列需求,不预定义字段名、HTTP 路径或动作代码。生产者交付真实 DTO/OpenAPI、权限、直接测试后再接写入;不在现有普通分批或跟进命令中夹带未实现的共约调整。
|
||||
|
||||
| 接入点与现有真实入口 | 可用部分 | 尚需生产者提供的精确语义 |
|
||||
|---|---|---|
|
||||
| `AppointmentBatchForm.vue`、`batchApi.ts`;GET `/appointment-planning/batches/{batchKey}`、POST `/appointment-planning/appointments/repartition` | 原来源核实、当前前端一次拆两批(服务端支持最多 20 批)、全部当前终点 | 车次/排车单到逐客户逐来源当前装载的引用;当前可调整量及版本;先送 0/全量/中间量各自资格;移出确认后原车保留结果与后送批次,是否有待确认草稿。不能把现有 splitBatch 的非空限制直接删除 |
|
||||
| `WorkAppointmentForm.vue`、`overseas-warehouse/api/inboundOrders.ts` 的 get/updateFbaAppointment;`api.ts` 的 deleteAppointment | 单批计划、确认、取消、原批续办、同键重试 | 独立共同预约身份、范围版本、当前成员/箱数/原确认时段/关联车辆、仅本客户或整份的允许动作;读取待回复变化及保存/确认/拒绝语义。旧事实与待确认输入分别返回,不能写普通计划来冒充移出成功 |
|
||||
| `WorkAppointmentForm.vue` 的重约衔接,后续独立共约表单只在合同冻结后装配 | 已取消单批沿原号 update,保留分配 | 整组重约的当前候选、已退出/已另约/已完成/其他处理中成员与原因、各成员当前去向;共用/独立结果、新有效关系身份、仓库覆盖确认依据、原车复用资格及范围并发校验 |
|
||||
| `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue`;`overseas-warehouse/api/dispatches.ts` 的 getBatchDispatchCandidates、草稿锁定/预排;既有 TripPreparationDialog | 按真实托盘、每客户排车,再沿已有排车单安排车辆 | 已按当前真实整托候选分别创建客户排车单,再从结果页使用另一张已备排车单业务号共车;本批默认只选当前合格实物。部分箱量无法映射时先走原库存拆托,非本轮新建任意拆托接口需求;真实资源资格和装载链待 HTTP |
|
||||
| `overseas-master-data/pages/TripDetailView.vue` 本车排车单及 `overseas-warehouse/pages/DispatchesView.vue` | 原业务排车、审核、装载、发车规则继续使用;当前任务未修改这些页面 | 本车客户卡片的客户业务名称、batchKey/来源/数量/当前预约覆盖、未确认变化、当前去向与可调整目标;对应允许动作;原车无货的权威关闭结果。不能以解除排车单关联替代客户移出 |
|
||||
| `WorkDetailPanel.vue`、`workApi.ts` 的 GET `/appointment-work/items/{key}` | 消费 allowedActions、nextAction、blockReasons、relatedItems;保存成功后刷新 | 新命令的稳定目标与白名单动作、受影响工作键、原批到当前批次的工作项引用。当前 currentBatches 只有预约快照,子批保存后不能凭业务号或拼接 batchKey 猜新工作键;目前仍刷新进入时的原事项 |
|
||||
| `WorkDetailPanel.vue`、`AppointmentCalendarView.vue`、`workNavigation.ts` | 日期、月份、流程、类型、范围、分页及原事项返回已有支持 | 新结果逐步骤成功/失败/待补、已持久保存对象、失败步骤允许的重试动作和幂等身份;用于只重试失败部分,并明确原车和后送需求都存在 |
|
||||
|
||||
独立修复:`WorkAppointmentForm` 首次读取失败后重试须初始化真实确认资料和偏移;版本冲突后仍保留已编辑输入。`AppointmentBatchForm` 切换当前去向先清除旧快照,首次读新批失败再重试须按新批初始化日期/数量,不能复用旧批草稿。对应新增直接测试分别在同名 `.test.ts`;不涉及新合同或环境写入。本次仅运行上述两个测试文件,16 项通过(新增 2 项);未重复运行上一批完整模块检查。
|
||||
|
||||
只读联审的子批续办缺口已接入真实 GET `/appointment-work/items/resolve?batchKey=...&customerId=...`:`BatchWorkResolution` 返回唯一 detail 或多客户 candidates。`WorkBatchContinuation.vue` 多候选必须明确选客户,再读权威 detail;读失败只重试 GET。`WorkAppointmentForm` 保存返回真实预约快照,`WorkDetailPanel` 用其 batchKey 定位子批,刷新摘要但不再先刷新旧事项/弹回旧事项。`WorkDetailPanel.test.ts` 与 `AppointmentCalendarView.test.ts` 已覆盖子批下一步、车辆续办、返回旧事项及原未定日期分页;工作键不在浏览器拼接。
|
||||
|
||||
### V13 集成操作与判定清单(待合同与集成窗口)
|
||||
|
||||
以下是验收设计,不是通过记录。01–11 每项恢复独立的甲 80、乙 160、丁 40 基准,均按客户+来源+目的地核对,不跨场景累计;涉及环境写入时由总控按授权窗口执行。
|
||||
|
||||
| 场景 | 操作与必须观察到的结果 |
|
||||
|---|---|
|
||||
| 01 | 原车乙卡片先送 0,确认仓库回复后原车 120、乙后送 160;沿乙入口重新约仓/用车,其他客户确认不变,无新增整票占量 |
|
||||
| 02 | 乙先送 100、后送 60,后送不填日期;原车 220,60 在全局未定日期可续办,跟进日不填入送货日 |
|
||||
| 03 | 从 60 入口按实际托盘形成客户排车单;另一客户 40 同理。结果页核对两单业务号与真实同仓资格后,沿 Fleet 安排同一车次;分别/共同约仓范围可辨,一车 100,客户归属保留。实物不匹配先走原库存拆托 |
|
||||
| 04 | 后送 60 再选 40,原车客户卡片列 40 新批与 20 待排各一次;两者可直达,不把合并的其他客户数算入乙 |
|
||||
| 05 | 后送量全部再分配,旧入口显示全部当前终点;中间历史、0 箱卡片和重复后送待办均不出现 |
|
||||
| 06 | 调整后先保存待仓库回复,退出再进入仍有待确认输入,旧有效安排保留;未解决前发车被阻断,继续明确回复后只影响对应范围 |
|
||||
| 07 | 退出客户填写他人有效旧约号,提交冲突后保留号码、时段及输入;不自动加入共同预约;重新读取范围后明确再提交 |
|
||||
| 08 | 选择整份共同改期,先核对完整当前成员和车辆;保存同一新时段只影响相关已安排工作,再次无变化保存与复核不新生复核 |
|
||||
| 09 | 整份取消后旧确认和失效目标清除、历史保留;共同重约只选仍需履约成员,仓库确认覆盖后产生新有效关联,沿可用原车续办 |
|
||||
| 10 | 一名成员先独立另约,再进入整组重约;其被排除并显示去向,退出/已完成/其他处理中成员同样不可静默加入;其他成员可分别重约 |
|
||||
| 11 | 先送为 0 合法,全量先送不产生空后送;原车所有有效货物移出后按原车命令关闭待办,已发车则进入原异常处理,不能普通移出 |
|
||||
| 12 | 未定日期分页进入→切月份/图层→当前批续办→返回;原范围、分页、日期及筛选恢复,全局待办不因日历筛选丢失 |
|
||||
| 13 | 分别在货物保存、车辆关联、读取下一步失败处中断;只重试失败步骤,未知结果同键,已成功批次/排车/车次不重复创建;显示真实部分结果 |
|
||||
| 14 | 只读权限、来源不可用、版本变化、约号冲突、跨日和 DST 逐项验证;首次 GET 失败重试不得出现空白可写旧事实,冲突后保留输入,未知数量不作 0 |
|
||||
| 15 | 五类高计数仍固定日格,同车多批只计一辆车;中转与直送分别走真实备货/卸货/还空门槛,未完成卸货或未接手责任不能借复核结束工作 |
|
||||
|
||||
阻塞场景不以按钮禁用、静态截图或 Mock 成功响应记为已验收。合同冻结后先扩充对应组件/API 定向测试,再由总控安排真实场景及 1920×1080、100% 人工截图。本次读取恢复修复后的 `pnpm typecheck` 和 `git diff --check` 均通过;未执行全量构建、真实 HTTP 或部署。
|
||||
|
||||
| 本次操作 | 证据与现状 | 调整结论 |
|
||||
|---|---|---|
|
||||
| 未定日期范围内搜索 | 用户截图 `codex-clipboard-07b5ab03-e467-4ce1-8410-13d05ac5bc51.png` 反馈大量数据缺少搜索,且测试环境仍显示旧卡片 | 全局待办弹框接入 QueryToolbar,支持柜号、批次号、车次号、地点关键词;沿现有 items API 同时传 view(含 UNDATED)与 keyword,服务端全范围筛选后分页,不过滤当前 8 条。搜索/清空回第 1 页,返回明细保留搜索词和分页,原业务跳转的 return URL 保留 workListKeyword。保留全局统计口径;表格与搜索均为当前工作区改动,本任务未部署,未运行验证 |
|
||||
| 所属客户缺失排查 | 用户截图 `codex-clipboard-645f5354-0dfd-4b90-b6f7-05ff9b1f2ffb.png`;`BusinessReference` 只有 kind/key/businessNo/label,客户引用存在 `CUSTOMER + 内部客户键 + null + 所属客户` 的生产路径,详情查询仅合并引用 | 前端按 `businessNo || '—'` 展示,缺少真实客户名称;不能据此认定没有关联客户,也不能回退内部客户键。待工作事项后端按权威客户目录补充名称/业务编号并明确展示合同;遵循开发协作契约,本任务不保留后端改动。未完成接口或环境验收 |
|
||||
| 提醒列与跳转遮罩 | 用户截图 `codex-clipboard-54e0b56d-448c-40da-a147-15798c932091.png` 和按钮反馈 | 提醒列固定 105px,仅显示去重后的条数,点击展开完整可滚动提醒,不撑高表格行。预约页被 KeepAlive 停用时卸载挂到 body 的弹框,恢复时保留父级事项/分页上下文。用户 startTime 截图实际是 INP 采集的空 entries 读取,脚本来源尚待确认,不能将该报错认定为已修复;本轮未运行验证或部署 |
|
||||
| 事项详情的信息层级 | 用户 2026-10-16 截图 `codex-clipboard-cad4abc6-51d1-42f2-8e7b-7914b264348e.png` 反馈排版不清楚、缺少重点 | 顶部突出事项名称与状态;作业时间、时区、地点、负责人和下次跟进复用共享描述表;原有提醒去重后集中展示,保留完成态与跟进日期语义;当前操作与关联资料/历史分区,保留所有动作资格、原业务跳转、默认历史展开条件和未保存保护。空原业务进度不渲染空标题;已知事件代码使用中文名称,未知代码保留在辅助信息中,时间保留原时区。本轮未运行验证或部署,待人工截图确认 |
|
||||
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
|
||||
| 工作明细左侧长操作按钮 | 用户 2026-10-16 截图 `codex-clipboard-b36b905a-a223-4fa6-98ca-7ece08149a37.png` 反馈按钮过大;原 108px 列宽、全宽实心按钮导致长文案换行撑高 | 操作列调整为 184px,按钮按文案宽度使用共享 `density-action` 描边样式和 28px 最小高度;完整保留服务端动作标签及点击行为,超长文案仍可换行。业务编号使用深色标题,辅助信息使用说明字号,行间距收紧。适用全部工作明细弹框及全屏态,覆盖早期实心主按钮结论;改后待人工截图确认,本轮未运行验证或部署 |
|
||||
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
|
||||
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
|
||||
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
|
||||
| 详情与编辑 | 原型 `renderTask()`、`followForm()` | 保留原侧边详情/表单和原业务跳转;关闭、切换和返回仍经过未保存保护,扩宽不重建表单 |
|
||||
| 今日需跟进 | 仅核对 `AppointmentWorkPolicy.followToday` 和 `AppointmentWorkQueryService.summary` | 保留服务端统计、接口参数及前端数字,不在本次视觉调整中改口径 |
|
||||
|
||||
| 页面/操作 | 原型证据 | 承载与布局 | 实现入口及核对范围 |
|
||||
|---|---|---|---|
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,固定高度日格,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,紧凑日格随内容自然增高,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 全部待办、今日、超期、未定日期、复核 | `.inboxbar`、`backlog()`、`renderList()` | 日历上方全局入口;侧边分页明细,操作在左,范围明确 | `workApi.ts`;summary 不带月历条件,全关图层不影响全局查询 |
|
||||
| 日期、类型与本柜连续工作 | `openDay()`、`renderList()` 的 container 模式 | 同一侧边面板;确认路线后回本柜未完成事项 | `WorkDetailPanel.vue`;保存刷新摘要、日历和当前范围 |
|
||||
| 下一步、前置事项与返回 | `nextAction()`、`renderTask()`、`.origin-context` | 主按钮直接处理当前下一步;前置项保留原事项返回入口 | 动作 code/target 白名单,重新读取目标资格;原业务执行仍回原入口 |
|
||||
@@ -19,5 +216,8 @@
|
||||
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReceivingMember, ReceivingTransport } from './receivingApi'
|
||||
defineProps<{ members: ReceivingMember[]; transport?: ReceivingTransport }>()
|
||||
</script>
|
||||
<template>
|
||||
<div aria-label="当前覆盖范围">
|
||||
<div v-for="(member, index) in members" :key="`${member.batchKey}:${member.customerId}`" class="receiving-member">
|
||||
<b>{{ member.appointmentNo }} · {{ member.customerName || '—' }} · 范围 {{ index + 1 }}</b>
|
||||
<p v-for="source in member.sources" :key="`${source.sourceKey}:${source.destinationKey}`">{{ source.inboundCode || '—' }} · {{ source.packageCount }} 箱</p>
|
||||
</div>
|
||||
<p v-if="!members.length">当前无可纳入的货物。</p>
|
||||
<template v-if="transport"><p v-for="(vehicle, index) in transport.vehicles" :key="index">车辆:{{ vehicle.tripNo || '车次未关联' }} · 排车单 {{ vehicle.dispatchNo || '未关联' }} · {{ vehicle.packageCount }} 箱</p><p v-for="reason in transport.blockReasons" :key="reason" role="status">{{ reason }}</p><p v-if="!transport.vehicles.length">当前未关联车辆。</p></template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.receiving-member { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TripAppointmentPanel from './TripAppointmentPanel.vue'
|
||||
import { getDispatch, getFbaAppointment, type DispatchBatchReference } from '@/modules/overseas-warehouse'
|
||||
import { getAppointmentBatch } from './batchApi'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, recordVersion: 0, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'initialCustomerId'], template: '<div data-receiving />' },
|
||||
WorkBatchContinuation: { name: 'WorkBatchContinuation', props: ['batchKey', 'customerId'], template: '<div data-continuation />' },
|
||||
AppointmentBatchForm: true, WorkAppointmentForm: true,
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' },
|
||||
} } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||
vi.mocked(getReceivingContext).mockResolvedValue({ batch: batchContext().appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'execution-v1', members: [] }, transport: { version: 'scope-v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] })
|
||||
})
|
||||
it('renders all current 40/20 destinations for the bound customer and directly opens the exact customer adjustment', async () => {
|
||||
const context = batchContext(), forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
forty.items[0]!.packageCount = 40; forty.items[1]!.packageCount = 999; twenty.items = [{ ...context.appointment.items[0]!, packageCount: 20 }]
|
||||
context.currentBatches = [forty, twenty]; vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱'); expect(wrapper.text()).not.toContain('999')
|
||||
expect(wrapper.text()).not.toContain('internal-dispatch'); expect(wrapper.text()).not.toContain('customer-a')
|
||||
await wrapper.findAll('button').find(button => button.text() === '调整本客户先送 / 后送')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkReceivingPanel' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', initialCustomerId: 'customer-a' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('continues a current destination by its batch and customer rather than guessing a work key', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续本批当前工作')!.trigger('click'); await flushPromises()
|
||||
const continuation = wrapper.getComponent({ name: 'WorkBatchContinuation' })
|
||||
expect(continuation.props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a' })
|
||||
continuation.vm.$emit('open', 'real-work-key'); expect(wrapper.emitted('work')).toEqual([['real-work-key']]); wrapper.unmount()
|
||||
})
|
||||
it('uses historical source refs when the bound customer has completely left the original batch', async () => {
|
||||
const context = batchContext(), moved = context.appointment.items[0]!
|
||||
context.appointment.items = [context.appointment.items[1]!]
|
||||
const current = batchContext('AP-LATER', 'FBA_APPOINTMENT:2').appointment
|
||||
current.items = [{ ...moved, packageCount: 70 }]; context.currentBatches = [context.appointment, current]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('AP-LATER')
|
||||
expect(wrapper.text()).toContain('IN-A · 70 箱')
|
||||
expect(wrapper.text()).not.toContain('范围当前不可匹配'); wrapper.unmount()
|
||||
})
|
||||
it('reports missing references and read failures without looking up legacy appointment numbers', async () => {
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [] }] }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('没有预约批次引用')
|
||||
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); expect(getAppointmentBatch).not.toHaveBeenCalled()
|
||||
vi.mocked(getAppointmentBatch).mockRejectedValueOnce(new Error('read failed'))
|
||||
await wrapper.setProps({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('读取失败'); wrapper.unmount()
|
||||
})
|
||||
it('reads all stable references without a legacy appointment number and deduplicates repeated bindings', async () => {
|
||||
vi.mocked(getAppointmentBatch).mockImplementation(async key => batchContext(key === 'FBA_APPOINTMENT:1' ? 'AP-001' : 'AP-002', key))
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => ({ batch: batchContext('AP', key).appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'v1', members: [] }, transport: { version: 'v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] }))
|
||||
const refs = [reference(), reference('FBA_APPOINTMENT:2', 'AP-002')]
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 100, appointmentBatches: refs }, { dispatchNo: 'DS-B', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.findAll('article')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('DS-A、DS-B'); expect(wrapper.text()).toContain('AP-002')
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(getReceivingContext).toHaveBeenCalledTimes(2)
|
||||
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('reenters a removed customer through history and displays only current 40/20 source destinations', async () => {
|
||||
const context = batchContext(), source = context.appointment.items[0]!
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
forty.items = [{ ...source, packageCount: 40 }, { ...source, id: 'other', sourceKey: 'ORDER:OTHER', inboundCode: 'OTHER-SOURCE', packageCount: 888 }]
|
||||
twenty.items = [{ ...source, packageCount: 20 }, { ...context.appointment.items[1]!, packageCount: 777 }]
|
||||
context.originalSourceRefs.push({ customerId: 'customer-a', sourceKey: 'ORDER:OTHER', destinationKey: 'FBA:ONT8' })
|
||||
context.appointment.items = []; context.currentBatches = [forty, twenty, forty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const previous = { dispatchNo: 'DS-REMOVED', customerId: 'customer-a', recordVersion: 4, recordedAt: '2026-10-16T01:00:00Z', appointmentBatches: [{ ...reference(), sources: [{ ...reference().sources[0]!, packageCount: 9999 }] }] }
|
||||
const wrapper = render({ dispatches: [], previousDispatches: [previous, { ...previous, recordVersion: 5 }] }); await flushPromises()
|
||||
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('仅供追溯'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱')
|
||||
for (const unexpected of ['9999', '888', '777', 'OTHER-SOURCE', '调整本客户先送 / 后送']) expect(wrapper.text()).not.toContain(unexpected)
|
||||
const buttons = wrapper.findAll('button').filter(button => button.text() === '继续本批当前工作'); expect(buttons).toHaveLength(2)
|
||||
await buttons[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkBatchContinuation' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:20', customerId: 'customer-a' })
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps current cards readable with incomplete history and never fabricates historical entries', async () => {
|
||||
const wrapper = render({ historyUnavailableReason: '部分原绑定记录缺少来源证据' }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('部分原绑定记录缺少来源证据')
|
||||
expect(wrapper.findAll('article')).toHaveLength(1); expect(wrapper.text()).toContain('当前关联排车单:DS-A')
|
||||
expect(wrapper.text()).not.toContain('历史关联排车单:'); expect(wrapper.text()).toContain('70 箱'); wrapper.unmount()
|
||||
})
|
||||
it('blocks invalid reference customer identity before reading or opening current work', async () => {
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'another-customer', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('客户或来源范围不完整')
|
||||
expect(getAppointmentBatch).not.toHaveBeenCalled(); expect(wrapper.text()).not.toContain('继续本批当前工作'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { type DispatchBatchReference, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getAppointmentBatch, type FbaAppointmentBatchContext } from './batchApi'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { TripPreviousDispatch } from '@/modules/overseas-master-data'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const props = defineProps<{ dispatches: Array<{ dispatchNo: string; customerId: string; packageCount: number; appointmentBatches: DispatchBatchReference[] }>; previousDispatches: TripPreviousDispatch[]; historyUnavailableReason: string | null; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; work: [key: string]; external: [path: string] }>()
|
||||
type Card = { key: string; batchKey: string; batchNo: string | null; customerId: string; current: boolean; dispatchNos: string[]; historyNos: string[]; sources: DispatchBatchReference['sources']; batch: FbaAppointmentBatchContext | null; receiving: ReceivingContext | null; error: ApiErrorDetails | null }
|
||||
const access = useAccessPreviewStore(), cards = ref<Card[]>([]), loading = ref(false)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const selected = ref<{ batchKey: string; customerId: string } | null>(null), mode = ref<'receiving' | 'batch' | 'appointment' | 'continue'>('receiving'), appointmentNo = ref('')
|
||||
const guards = new Set<LeaveGuard>()
|
||||
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 }
|
||||
defineExpose({ canLeave })
|
||||
onBeforeRouteLeave(canLeave)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
const current = ++generation; loading.value = true
|
||||
const batches = new Map<string, Promise<FbaAppointmentBatchContext>>(), receiving = new Map<string, Promise<ReceivingContext>>()
|
||||
const references = new Map<string, Card>()
|
||||
const add = (binding: { dispatchNo: string; customerId: string; appointmentBatches: DispatchBatchReference[] }, historical: boolean) => {
|
||||
for (const reference of binding.appointmentBatches) {
|
||||
const key = JSON.stringify([reference.batchKey, reference.customerId])
|
||||
let card = references.get(key)
|
||||
if (!card) { card = { key, batchKey: reference.batchKey, batchNo: reference.batchNo, customerId: reference.customerId, current: false, dispatchNos: [], historyNos: [], sources: [], batch: null, receiving: null, error: null }; references.set(key, card) }
|
||||
if (reference.customerId !== binding.customerId || !reference.batchKey || !reference.sources.length) card.error = { message: '排车引用的客户或来源范围不完整,请核对原排车单。' }
|
||||
card.current ||= !historical
|
||||
const numbers = historical ? card.historyNos : card.dispatchNos
|
||||
if (!numbers.includes(binding.dispatchNo)) numbers.push(binding.dispatchNo)
|
||||
for (const source of reference.sources) if (!card.sources.some(existing => existing.sourceKey === source.sourceKey && existing.destinationKey === source.destinationKey)) card.sources.push(source)
|
||||
}
|
||||
}
|
||||
props.dispatches.forEach(binding => add(binding, false))
|
||||
props.previousDispatches.forEach(binding => add(binding, true))
|
||||
const loaded = await Promise.all([...references.values()].map(async card => {
|
||||
if (card.error) return card
|
||||
try {
|
||||
if (!batches.has(card.batchKey)) batches.set(card.batchKey, getAppointmentBatch(card.batchKey))
|
||||
if (!receiving.has(card.batchKey)) receiving.set(card.batchKey, getReceivingContext(card.batchKey))
|
||||
const [batch, scope] = await Promise.all([batches.get(card.batchKey)!, receiving.get(card.batchKey)!])
|
||||
if (batch.appointment.batchKey !== card.batchKey || scope.batch.batchKey !== card.batchKey) throw new Error('批次范围不匹配')
|
||||
card.batch = batch; card.receiving = scope
|
||||
} catch (cause) { card.error = getApiErrorDetails(cause, '本客户预约与去向读取失败') }
|
||||
return card
|
||||
}))
|
||||
if (current === generation) { cards.value = loaded; loading.value = false }
|
||||
}
|
||||
function customerName(card: Card) { return card.receiving?.agreements.flatMap(row => row.members).find(member => member.customerId === card.customerId)?.customerName || '—' }
|
||||
function destinations(card: Card) {
|
||||
if (!card.batch) return []
|
||||
const original = new Set(card.batch.originalSourceRefs.filter(item => item.customerId === card.customerId && card.sources.some(source => source.sourceKey === item.sourceKey && source.destinationKey === item.destinationKey)).map(item => JSON.stringify([item.sourceKey, item.destinationKey])))
|
||||
return [...new Map(card.batch.currentBatches.map(batch => [batch.batchKey, batch])).values()].map(batch => ({ batch, items: batch.items.filter(item => item.customerId === card.customerId && original.has(JSON.stringify([item.sourceKey, item.destinationKey])) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
|
||||
}
|
||||
async function open(batchKey: string, customerId: string, next: 'receiving' | 'batch' | 'continue') { if (await canLeave()) { selected.value = { batchKey, customerId }; mode.value = next } }
|
||||
async function openAppointment(number: string) { if (await canLeave()) { appointmentNo.value = number; mode.value = 'appointment' } }
|
||||
async function close(done: () => void) { if (await canLeave()) { done(); selected.value = null } }
|
||||
function saved(appointment: FbaAppointmentDto) { if (appointment.batchKey && selected.value) { selected.value = { ...selected.value, batchKey: appointment.batchKey }; mode.value = 'continue' }; updated() }
|
||||
function updated() { emit('updated'); void load() }
|
||||
watch(() => JSON.stringify([props.dispatches, props.previousDispatches]), () => { void load() })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本车客户预约与后送去向">
|
||||
<p v-if="!readable">需要预约及排车列表权限才能读取客户预约范围。</p>
|
||||
<p v-if="loading">正在核对本车客户的预约与当前去向…</p>
|
||||
<el-button :loading="loading" @click="load">刷新客户预约与去向</el-button>
|
||||
<p v-if="historyUnavailableReason" role="alert">历史预约引用读取不完整:{{ historyUnavailableReason }}。当前车次仍可查看,请重试读取后再核对历史去向。</p><p v-for="binding in dispatches.filter(row => !row.appointmentBatches.length)" :key="binding.dispatchNo" role="status">{{ binding.dispatchNo }}:本排车单没有预约批次引用,请从原排车单核对业务来源。</p><article v-for="card in cards" :key="card.key" class="trip-customer-card">
|
||||
<h4>{{ customerName(card) }} · {{ card.batchNo || '批次业务号未提供' }}</h4><p v-if="card.current">当前关联排车单:{{ card.dispatchNos.join('、') }}</p><p v-if="card.historyNos.length">历史关联排车单:{{ card.historyNos.join('、') }} · 仅供追溯,不代表当前装载或可排数量。</p>
|
||||
<LoadErrorState v-if="card.error" title="本客户范围不可用" :message="card.error.message" @retry="load" />
|
||||
<template v-if="card.batch && card.receiving">
|
||||
<p v-for="pending in card.receiving.pendingChanges.filter(row => row.intent.customerId === card.customerId)" :key="pending.changeKey" role="status">待仓库回复:{{ pending.intent.reason }}。原安排保留,请继续处理。</p>
|
||||
<el-button v-if="card.current" :disabled="loading || !canWrite" @click="open(card.batch.appointment.batchKey!, card.customerId, 'receiving')">调整本客户先送 / 后送</el-button>
|
||||
<el-button v-if="card.current" :disabled="loading" @click="open(card.batch.appointment.batchKey!, card.customerId, 'batch')">查看批次与完整去向</el-button>
|
||||
<div v-for="destination in destinations(card)" :key="destination.batch.batchKey"><b>{{ destination.batch.appointmentNo }}</b><p>{{ destination.batch.plannedDate || '日期未定' }}</p><p v-for="item in destination.items" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><el-button :disabled="loading" @click="open(destination.batch.batchKey!, card.customerId, 'continue')">继续本批当前工作</el-button></div>
|
||||
<p v-if="!destinations(card).length">原客户来源范围当前不可匹配,请核对完整批次去向;此处不代表已完成或没有后送需求。</p>
|
||||
</template>
|
||||
</article>
|
||||
<p v-if="readable && !loading && !cards.length">当前没有可读取的预约批次引用。</p>
|
||||
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
|
||||
<template v-if="selected">
|
||||
<WorkReceivingPanel v-if="mode === 'receiving'" :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :initial-customer-id="selected.customerId" :can-write="canWrite" @updated="updated" @appointment="openAppointment" @batch="key => open(key, selected!.customerId, 'batch')" @external="path => emit('external', path)" />
|
||||
<AppointmentBatchForm v-else-if="mode === 'batch'" :key="selected.batchKey" :batch-key="selected.batchKey" @updated="updated" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="mode === 'appointment'" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="canWrite" @saved="saved" @batch="key => open(key, selected!.customerId, 'batch')" @receiving="key => open(key, selected!.customerId, 'receiving')" />
|
||||
<WorkBatchContinuation v-else :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :customer-id="selected.customerId" @open="key => emit('work', key)" />
|
||||
</template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }</style>
|
||||
@@ -3,19 +3,42 @@ import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { deleteAppointment } from './api'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
|
||||
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
|
||||
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: true, 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', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
|
||||
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
|
||||
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).exists()).toBe(false)
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.confirmationNo).toBe('EXT-001'); expect(model.appointmentStatus).toBe('SUCCESS')
|
||||
expect(model.appointmentAt).toBe('2026-10-15T23:30:00'); expect(model.startOffset).toBe('-07:00')
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('preserves both dates of an existing cross-day appointment and its explicit offsets in the original business command', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').appointmentRemark = '已核对仓库回复'
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 3, details: expect.objectContaining({ appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', startOffset: '-07:00', endOffset: '-07:00' }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not resubmit unchanged appointment facts, including whitespace-only edits', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const save = wrapper.findAll('button').find(button => button.text() === '保存预约安排')!
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo = ' EXT-001 '
|
||||
await flushPromises(); await save.trigger('click')
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('无需重复保存'); wrapper.unmount()
|
||||
})
|
||||
it('does not edit or cancel replaced snapshots and offers the stable batch context for replacement history', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue({ ...detail, replacementAppointmentNos: ['AP-NEW'], replacementBatchKeys: ['FBA_APPOINTMENT:2'], repartitionReason: '分批送仓' })
|
||||
const wrapper = render(true); await flushPromises()
|
||||
@@ -25,3 +48,44 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看替代批次')!.trigger('click')
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
|
||||
})
|
||||
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
|
||||
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物分配仍保留')
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
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 }) }))
|
||||
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
|
||||
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries an uncertain rebooking using the identical command, and preserves input on an explicit conflict', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 6 })
|
||||
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('unknown outcome')).mockRejectedValueOnce(new ApiResponseError('外部约号已占用', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
|
||||
Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
expect(fields.props('disabled')).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2, 'AP-001', first)
|
||||
expect(fields.props('model').confirmationNo).toBe('NEW-EXT'); expect(fields.props('disabled')).toBe(false)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
const next = vi.mocked(updateFbaAppointment).mock.calls[2]![1]
|
||||
expect(next.version).toBe(6); expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('cannot cancel twice or rebook with read-only access', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(cancelled)
|
||||
const wrapper = render(true); await flushPromises()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.setProps({ cancel: false, canWrite: false }); await flushPromises()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.attributes('disabled')).toBeDefined()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); expect(deleteAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -9,23 +9,33 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { deleteAppointment } from './api'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: []; batch: [key: string] }>()
|
||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
||||
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(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)
|
||||
let initialized = false
|
||||
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
|
||||
const pending = ref<SaveRequest | null>(null)
|
||||
useWorkDraft(computed(() => JSON.stringify(form) !== baseline.value || !!reason.value), computed(() => saving.value || !!pending.value))
|
||||
const changed = computed(() => JSON.stringify(appointmentPayload(form)) !== baseline.value)
|
||||
useWorkDraft(computed(() => changed.value || !!reason.value), computed(() => saving.value || !!pending.value))
|
||||
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
|
||||
const writable = computed(() => props.canWrite && !replaced.value && detail.value?.appointmentStatus !== 'CANCELLED' && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
const rebooking = computed(() => !props.cancel && detail.value?.appointmentStatus === 'CANCELLED' && !replaced.value)
|
||||
const writable = computed(() => props.canWrite && !replaced.value && (!props.cancel || detail.value?.appointmentStatus !== 'CANCELLED') && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; error.value = null
|
||||
try { detail.value = await getFbaAppointment(props.appointmentNo); if (!preserve) { Object.assign(form, createAppointmentForm(detail.value)); baseline.value = JSON.stringify(form) } }
|
||||
try {
|
||||
detail.value = await getFbaAppointment(props.appointmentNo)
|
||||
if (!preserve || !initialized) {
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
|
||||
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
|
||||
}
|
||||
}
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
|
||||
if (!props.cancel && !pending.value && !changed.value && !rebooking.value) return
|
||||
if (!pending.value) {
|
||||
if (props.cancel) {
|
||||
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
|
||||
@@ -38,9 +48,9 @@ async function save() {
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
|
||||
else await updateFbaAppointment(props.appointmentNo, pending.value.payload)
|
||||
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved'); await load()
|
||||
else detail.value = await updateFbaAppointment(props.appointmentNo, pending.value.payload)
|
||||
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(appointmentPayload(form))
|
||||
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved', detail.value); await load()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
@@ -48,18 +58,21 @@ onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约安排">
|
||||
<h3>{{ cancel ? '取消本次预约' : '登记预约安排' }}</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>
|
||||
<p v-if="loading">正在读取原预约…</p>
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
|
||||
<p v-if="rebooking">原预约已取消,货物分配仍保留。重新预约将更新本批安排,保留取消历史;请重新填写目标日期或仓库确认,日期未定也可先保存待预约。</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 && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
|
||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
|
||||
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
||||
<p v-if="!writable">当前预约仅可查看。</p>
|
||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : '保存预约安排' }}</el-button>
|
||||
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
|
||||
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error || (!cancel && !rebooking && !pending && !changed)" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : rebooking ? '保存本批重新预约' : '保存预约安排' }}</el-button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.text()).toContain('无需重复提交')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['actual-key']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toBeUndefined(); expect(wrapper.text()).toContain('IN-A'); expect(wrapper.text()).not.toContain('customer-a')
|
||||
await wrapper.findAll('button')[0]!.trigger('click'); await flushPromises()
|
||||
expect(resolveBatchWork).toHaveBeenLastCalledWith('FBA_APPOINTMENT:40', 'customer-a', expect.any(AbortSignal))
|
||||
expect(wrapper.emitted('open')).toEqual([['current-work-a']]); wrapper.unmount()
|
||||
})
|
||||
it('keeps saved results on resolution failure and only retries the read', async () => {
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new Error('同步未完成')).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('无需重复提交'); expect(wrapper.emitted('open')).toBeUndefined()
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(resolveBatchWork).toHaveBeenCalledTimes(2); expect(wrapper.emitted('open')).toEqual([['actual-key']]); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolveBatchWork, type BatchWorkResolution } from './workApi'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
|
||||
const emit = defineEmits<{ open: [key: string] }>()
|
||||
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const controller = new AbortController()
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load(customerId = props.customerId) {
|
||||
loading.value = true; error.value = null
|
||||
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => controller.abort())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="当前批次续办">
|
||||
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
|
||||
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import { workCalendar } from './work.test-support'
|
||||
import { workTypes } from './workApi'
|
||||
function render() { return mount(WorkCalendarPanel, { props: { anchor: '2026-10-15', mode: 'month', flow: '', types: [...workTypes], calendar: workCalendar, loading: false, failed: false, reference: false, arrivals: [], referenceReady: false } }) }
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('keeps a busy day at five type summaries and consumes authoritative transport counts unchanged', async () => {
|
||||
const wrapper = render()
|
||||
const counts = { PICKUP: 300, WAREHOUSE_RECEIPT: 300, DELIVERY: 2, TRANSPORT: 1, EMPTY_RETURN: 300 }
|
||||
await wrapper.setProps({ calendar: { ...workCalendar, days: [{ ...workCalendar.days[0]!, total: 903, typeCounts: counts }] } })
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.findAll('.type-counts button')).toHaveLength(5)
|
||||
expect(day.get('[data-type="TRANSPORT"]').text()).toContain('1')
|
||||
expect(day.get('[data-type="DELIVERY"]').text()).toContain('2')
|
||||
await day.get('[data-type="TRANSPORT"]').trigger('click')
|
||||
expect(wrapper.emitted('day')).toEqual([['2026-10-15', 'TRANSPORT']]); wrapper.unmount()
|
||||
})
|
||||
it('offers all four flows directly and emits the original filter values with an exclusive selection', async () => {
|
||||
const wrapper = render()
|
||||
const buttons = wrapper.findAll('.flow-controls button')
|
||||
expect(buttons.map(button => button.text())).toEqual(['全部流程', '海外仓中转', '整柜直送', '路线待确认'])
|
||||
expect(wrapper.find('select').exists()).toBe(false)
|
||||
for (const [index, flow] of (['', 'TRANSFER', 'DIRECT', 'UNKNOWN'] as const).entries()) {
|
||||
await buttons[index]!.trigger('click')
|
||||
expect(wrapper.emitted('flow')?.at(-1)).toEqual([flow])
|
||||
await wrapper.setProps({ flow })
|
||||
expect(wrapper.findAll('.flow-controls button[aria-pressed=true]')).toHaveLength(1)
|
||||
expect(buttons[index]!.attributes('aria-pressed')).toBe('true')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('distinguishes today from the selected day and type without changing the selected scope', async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-15T12:00:00'))
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ selectedDate: '2026-10-16' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-current')).toBe('date')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.get('[data-date="2026-10-16"]').classes()).toContain('is-selected')
|
||||
await wrapper.setProps({ selectedDate: '2026-10-15', selectedType: 'DELIVERY' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="DELIVERY"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="PICKUP"]').attributes('aria-pressed')).toBe('false')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows the actual cross-month interval and hides empty-day zeros while keeping dates clickable', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('2026-09-28 ~ 2026-11-08')
|
||||
const empty = wrapper.get('[data-date="2026-10-01"] .day-heading')
|
||||
expect(empty.text()).toBe('1')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click'); expect(wrapper.emitted('day')).toEqual([['2026-10-01']])
|
||||
expect(wrapper.get('footer').text()).toContain('未定日期请从上方待办查看')
|
||||
await wrapper.setProps({ mode: 'week' })
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(7)
|
||||
expect(wrapper.get('.view-switch button:last-child').attributes('aria-pressed')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(42)
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(35)
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.get('.day-heading').text()).toContain('2 项')
|
||||
await day.get('[data-type="DELIVERY"]').trigger('click')
|
||||
|
||||
@@ -1,61 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { calendarDates, type CalendarMode } from './calendar'
|
||||
import { calendarDates, dateKey, type CalendarMode } from './calendar'
|
||||
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
|
||||
import { typeLabels } from './workPresentation'
|
||||
import { flowLabels, typeLabels } from './workPresentation'
|
||||
import type { ArrivalCalendarEntry } from './api'
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean }>()
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean; selectedDate?: string; selectedType?: WorkType }>()
|
||||
const emit = defineEmits<{ anchor: [offset: number]; today: []; mode: [value: CalendarMode]; flow: [value: WorkFlow | '']; types: [value: WorkType[]]; reference: [value: boolean]; day: [date: string, type?: WorkType]; arrivals: [date: string] }>()
|
||||
const days = computed(() => calendarDates(props.anchor, props.mode))
|
||||
const today = dateKey(new Date())
|
||||
const flows: Array<{ value: WorkFlow | ''; label: string }> = [{ value: '', label: '全部流程' }, { value: 'TRANSFER', label: flowLabels.TRANSFER }, { value: 'DIRECT', label: flowLabels.DIRECT }, { value: 'UNKNOWN', label: flowLabels.UNKNOWN }]
|
||||
const aggregates = computed(() => new Map(props.calendar?.days.map(day => [day.date, day]) || []))
|
||||
const heading = computed(() => props.mode === 'month' ? `${props.anchor.slice(0, 4)}年${Number(props.anchor.slice(5, 7))}月` : `${days.value[0]} — ${days.value.at(-1)}`)
|
||||
function toggle(type: WorkType, checked: boolean) { emit('types', checked ? [...props.types, type] : props.types.filter(value => value !== type)) }
|
||||
function arrivalCount(date: string) { return props.arrivals.filter(row => row.arrivalAt.slice(0, 10) === date).length }
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-calendar" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<section class="work-calendar" :class="{ 'week-view': mode === 'week' }" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<div v-if="$slots.inbox" class="global-work"><slot name="inbox" /></div>
|
||||
<header class="calendar-toolbar">
|
||||
<label>流程 <select aria-label="日历流程" :value="flow" @change="emit('flow', ($event.target as HTMLSelectElement).value as WorkFlow | '')"><option value="">全部流程</option><option value="TRANSFER">海外仓中转</option><option value="DIRECT">整柜直送</option><option value="UNKNOWN">路线待确认</option></select></label>
|
||||
<div class="period-controls"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" @click="emit('today')">今天</button></div>
|
||||
<div class="flow-controls" role="group" aria-label="日历流程"><span class="muted">流程</span><button v-for="option in flows" :key="option.value" type="button" :data-flow="option.value" :aria-pressed="flow === option.value" @click="emit('flow', option.value)">{{ option.label }}</button></div>
|
||||
<div class="period-controls"><div class="view-switch" aria-label="日历视图"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button></div><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" title="定位到浏览器当前日期" @click="emit('today')">今天</button></div>
|
||||
<span class="muted">作业地点当地时间</span>
|
||||
</header>
|
||||
<div class="work-layers"><span class="muted">显示类型</span><label v-for="type in workTypes" :key="type" :data-type="type"><input type="checkbox" :checked="types.includes(type)" @change="toggle(type, ($event.target as HTMLInputElement).checked)">{{ typeLabels[type] }}</label><label><input type="checkbox" :checked="reference" @change="emit('reference', ($event.target as HTMLInputElement).checked)">到港参考</label></div>
|
||||
<div class="calendar-counts" aria-live="polite">
|
||||
<span v-if="loading">正在读取工作统计…</span><span v-else-if="failed">工作统计加载失败</span><span v-else-if="!types.length">未勾选工作类型,全局待办仍可查看</span>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 · {{ calendar.total }} 项工作</strong><span>待安排 {{ calendar.counts.pending }}</span><span>已落实 {{ calendar.counts.confirmed }}</span><span>无需预约 {{ calendar.counts.noBookingRequired }}</span><span>需复核 {{ calendar.counts.review }}</span><span>已完成 {{ calendar.counts.completed }}</span></template>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 {{ calendar.from }} ~ {{ calendar.to }} · {{ calendar.total }} 项工作</strong><span :class="{ 'is-zero': calendar.counts.pending === 0 }">待安排 {{ calendar.counts.pending }}</span><span :class="{ 'is-zero': calendar.counts.confirmed === 0 }">已落实 {{ calendar.counts.confirmed }}</span><span :class="{ 'is-zero': calendar.counts.noBookingRequired === 0 }">无需预约 {{ calendar.counts.noBookingRequired }}</span><span :class="{ 'is-zero': calendar.counts.review === 0 }">需复核 {{ calendar.counts.review }}</span><span :class="{ 'is-zero': calendar.counts.completed === 0 }">已完成 {{ calendar.counts.completed }}</span></template>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span></div>
|
||||
<div class="calendar-days">
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)) }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span>{{ Number(date.slice(8)) }}</span><span>{{ !loading && !failed && types.length ? `${aggregates.get(date)?.total ?? 0} 项` : '—' }}</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)), 'is-today': date === today, 'is-selected': date === selectedDate }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-current="date === today ? 'date' : undefined" :aria-pressed="date === selectedDate" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span class="date-number">{{ Number(date.slice(8)) }}</span><small v-if="date === today" class="today-mark" title="浏览器当前日期">今天</small><span v-if="loading || failed || !types.length" class="day-total">—</span><span v-else-if="(aggregates.get(date)?.total ?? 0) > 0" class="day-total">{{ aggregates.get(date)?.total }} 项</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-pressed="date === selectedDate && type === selectedType" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<button v-if="reference && referenceReady && arrivalCount(date)" type="button" class="arrival-reference" @click="emit('arrivals', date)">到港参考 {{ arrivalCount(date) }} 柜</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="muted">点击日期或类型查看明细;到港参考不计工作量。已执行按实际日期展示。</footer>
|
||||
<footer class="muted">日历只展示有作业日期的事项,未定日期请从上方待办查看。已执行按实际日期展示;到港参考不计工作量。</footer>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-calendar { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface); }
|
||||
.calendar-toolbar, .period-controls, .work-layers, .calendar-counts { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-toolbar { justify-content: space-between; padding: var(--admin-space-5); }
|
||||
.calendar-toolbar label { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.global-work { border-bottom: 1px solid var(--cc-border); }
|
||||
.calendar-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); padding: var(--admin-space-2) var(--admin-space-5); }
|
||||
.flow-controls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2); }
|
||||
.period-controls strong { min-width: 100px; text-align: center; }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-2) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
|
||||
.calendar-counts { min-height: 32px; font-size: var(--admin-font-caption); }
|
||||
.calendar-counts { min-height: 24px; font-size: var(--admin-font-caption); }
|
||||
.calendar-counts .is-zero { color: var(--el-text-color-secondary); }
|
||||
.weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; padding-block: var(--admin-space-3); background: var(--el-fill-color-light); }
|
||||
.work-day { height: 140px; min-height: 140px; max-height: 140px; box-sizing: border-box; overflow: hidden; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-2); }
|
||||
.weekdays span:nth-last-child(-n+2) { color: var(--el-text-color-secondary); }
|
||||
.work-day { height: 146px; min-height: 146px; max-height: 146px; box-sizing: border-box; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-1); }
|
||||
.week-view .work-day { height: 270px; min-height: 270px; max-height: 270px; }
|
||||
.work-day:nth-child(7n) { border-right: 0; }
|
||||
.outside { background: var(--el-fill-color-lighter); }
|
||||
.day-heading { display: flex; justify-content: space-between; width: 100%; border: 0; padding: 0 var(--admin-space-2); min-height: 24px; background: transparent; }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 24px; gap: 2px; min-height: 76px; }
|
||||
.type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid currentColor; padding: 0 3px; font-size: var(--admin-font-caption); background: transparent; min-width: 0; min-height: 24px; }
|
||||
.work-calendar .day-heading { display: flex; align-items: center; gap: var(--admin-space-1); width: 100%; border: 0; padding: 0; min-height: 24px; background: transparent; margin-bottom: var(--admin-space-1); }
|
||||
.date-number { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 50%; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.is-today .date-number { color: var(--el-color-white); background: var(--el-color-primary); font-weight: 600; }
|
||||
.today-mark { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||||
.day-total { margin-left: auto; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-day.is-selected { box-shadow: inset 0 0 0 2px var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.outside .day-heading { color: var(--el-text-color-secondary); }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(24px, auto); gap: var(--admin-space-1); }
|
||||
.work-calendar .type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 0 var(--admin-space-1); font-size: var(--admin-font-caption); background: color-mix(in srgb, currentColor 7%, var(--admin-surface)); min-width: 0; min-height: 24px; white-space: nowrap; }
|
||||
.work-calendar .type-counts button:hover:not(:disabled) { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, var(--admin-surface)); }
|
||||
.work-calendar .type-counts button[aria-pressed=true] { border-color: currentColor; background: color-mix(in srgb, currentColor 14%, var(--admin-surface)); box-shadow: inset 0 0 0 1px currentColor; }
|
||||
.type-counts b { font-variant-numeric: tabular-nums; }
|
||||
[data-type=PICKUP] { color: var(--el-color-primary); } [data-type=WAREHOUSE_RECEIPT] { color: #8356ad; } [data-type=DELIVERY] { color: #27815c; } [data-type=TRANSPORT] { color: #b26a12; } [data-type=EMPTY_RETURN] { color: #a64f69; }
|
||||
.arrival-reference { border: 0; background: transparent; padding: 0; min-height: 18px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-calendar .arrival-reference { display: block; border: 0; background: transparent; padding: 0 var(--admin-space-1); margin-top: var(--admin-space-1); min-height: 24px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-calendar .arrival-reference:hover { color: var(--el-text-color-regular); background: var(--el-fill-color-light); }
|
||||
footer { padding: var(--admin-space-3) var(--admin-space-5); }
|
||||
button, select { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible, select:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
button { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; border: 1px solid var(--cc-border); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
|
||||
.view-switch { display: inline-flex; }
|
||||
.view-switch button { border-radius: 0; min-width: 36px; }
|
||||
.view-switch button:first-child { border-radius: var(--admin-radius) 0 0 var(--admin-radius); }
|
||||
.view-switch button:last-child { border-radius: 0 var(--admin-radius) var(--admin-radius) 0; margin-left: -1px; }
|
||||
.view-switch button[aria-pressed=true], .flow-controls button[aria-pressed=true] { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); color: var(--el-color-primary); position: relative; }
|
||||
button:hover:not(:disabled) { border-color: var(--el-color-primary); }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.muted { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
@@ -86,13 +87,13 @@ async function save() {
|
||||
</script>
|
||||
<template>
|
||||
<form class="density-form work-form" @submit.prevent="save">
|
||||
<h3>{{ action.label }}</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="missingContext" role="alert">{{ missingContext }}</p>
|
||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label><p>跟进日期不代替作业日期,未完成事项仍保留在待办中。</p></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label><p>若原安排需要改期,请先处理原安排,再记录核对结果。</p></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label></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 === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, overseasWarehousePermissions, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -11,7 +11,7 @@ import type { SourceReference } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import { workReason } from './workPresentation'
|
||||
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
|
||||
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
|
||||
const 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)
|
||||
@@ -35,7 +35,7 @@ async function save() {
|
||||
try {
|
||||
const result = await createSourceAppointment(pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved')
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('saved', result)
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '创建结果待确认,请重试本次请求')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load() }
|
||||
@@ -44,18 +44,23 @@ async function save() {
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="新建预约安排">
|
||||
<h3>新建预约安排</h3>
|
||||
<section class="work-form density-dialog-sections" aria-label="新建预约安排">
|
||||
<LoadErrorState v-if="error" title="预约货物预览失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对本次货物范围…</p>
|
||||
<template v-if="preview && !error">
|
||||
<p>{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}</p>
|
||||
<p v-for="source in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</p>
|
||||
<p v-for="field in preview.missingFields" :key="field">{{ workReason(field) }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete">货物备妥窗口待核实。</p>
|
||||
<FbaAppointmentFields :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="本次预约货物">
|
||||
<h3 class="density-dialog-section__title">本次预约货物</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">起运仓</th><td>{{ preview.originWarehouseName || '—' }}</td><th scope="row">目的FBA仓</th><td>{{ preview.destinationWarehouseCode || '—' }}</td></tr>
|
||||
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
|
||||
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
|
||||
</tbody></table>
|
||||
<p v-for="field in preview.missingFields" :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>
|
||||
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button>
|
||||
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,75 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDetailPanel from './WorkDetailPanel.vue'
|
||||
import { getWorkDetail, type WorkAction, type WorkDetail } from './workApi'
|
||||
import { getWorkDetail, resolveBatchWork, type WorkAction, type WorkDetail } from './workApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn(), resolveBatchWork: vi.fn() }))
|
||||
function render(detail: WorkDetail, initialAction?: WorkAction) {
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(detail)
|
||||
return mount(WorkDetailPanel, { props: { itemKey: detail.item.workItemKey, initialAction, canLeave: async () => true }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
CargoVerificationForm: { name: 'CargoVerificationForm', props: ['source'], template: '<div data-cargo />' },
|
||||
WorkCommandForm: { name: 'WorkCommandForm', props: ['detail', 'action'], template: '<div data-command />' },
|
||||
WorkAppointmentForm: { name: 'WorkAppointmentForm', props: ['appointmentNo', 'canWrite'], template: '<div data-appointment />' },
|
||||
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'canWrite'], template: '<div data-receiving-scope />' },
|
||||
WorkCreateAppointmentForm: { name: 'WorkCreateAppointmentForm', props: ['sources', 'canWrite'], template: '<div data-create />' },
|
||||
WorkDispatchForm: { name: 'WorkDispatchForm', props: ['batchKeys', 'canWrite'], template: '<div data-dispatch />' },
|
||||
WorkExistingDispatchForm: { name: 'WorkExistingDispatchForm', props: ['dispatches', 'canWrite'], template: '<div data-existing-dispatch />' },
|
||||
WorkReceiptPlans: { name: 'WorkReceiptPlans', props: ['containerBookingId'], template: '<div data-receiving />' },
|
||||
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
|
||||
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('navigates the server-selected transport next step outside the delivery write permissions', async () => {
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ nextAction: transport, allowedActions: [followAction] }) }))
|
||||
await flushPromises()
|
||||
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
|
||||
expect(wrapper.text()).not.toContain('操作资格已变化')
|
||||
expect(wrapper.find('[data-dispatch]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
const target = render(workDetail({ item: workItem({ workItemKey: 'transport-current', type: 'TRANSPORT', allowedActions: [], nextAction: null }) }), transport)
|
||||
await flushPromises()
|
||||
expect(target.find('[data-dispatch]').exists()).toBe(false)
|
||||
expect(target.emitted('open')).toBeUndefined()
|
||||
target.unmount()
|
||||
})
|
||||
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
|
||||
const wrapper = render(workDetail(), followAction); await flushPromises()
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ nextAction: transport, allowedActions: [transport, followAction] }) }))
|
||||
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
|
||||
expect(wrapper.find('[data-command]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('本次保存已完成')
|
||||
expect(wrapper.get('.primary-next-action').text()).toBe('继续安排本批车辆')
|
||||
expect(wrapper.findAll('.more-actions button').map(button => button.text())).toEqual(['记录回复'])
|
||||
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not repeat a saved command or enable stale actions when the next-step read fails', async () => {
|
||||
const wrapper = render(workDetail(), followAction); await flushPromises()
|
||||
vi.mocked(getWorkDetail).mockRejectedValueOnce(new Error('下一步服务暂不可用'))
|
||||
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('无需重复提交已保存内容')
|
||||
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows an undated job separately from its follow-up date and flags missing follow-up ownership', async () => {
|
||||
const wrapper = render(workDetail()); await flushPromises()
|
||||
expect(wrapper.get('.followup-summary').text()).toContain('2026-10-15')
|
||||
expect(wrapper.get('.followup-summary').text()).toContain('作业日期未定,跟进日不作为作业日期')
|
||||
wrapper.unmount()
|
||||
const missing = render(workDetail({ item: workItem({ owner: null, followupDate: null }) })); await flushPromises()
|
||||
expect(missing.text()).toContain('负责人待分派')
|
||||
expect(missing.text()).toContain('请补充下一次跟进安排')
|
||||
missing.unmount()
|
||||
})
|
||||
it('never sends customer-warehouse dispatch batches to the FBA candidate endpoint', async () => {
|
||||
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
|
||||
@@ -81,6 +132,41 @@ it('uses the matched appointment business number and never passes the internal a
|
||||
expect(missing.findComponent({ name: 'WorkAppointmentForm' }).exists()).toBe(false)
|
||||
expect(missing.text()).toContain('尚未关联可操作的预约记录'); missing.unmount()
|
||||
})
|
||||
it('opens the receiving scope for the exact batch without changing the current work identity or losing result context', async () => {
|
||||
const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
|
||||
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-TEST', label: '预约' }] }), action)
|
||||
await flushPromises(); wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
|
||||
const scope = wrapper.getComponent({ name: 'WorkReceivingPanel' })
|
||||
expect(scope.props('batchKey')).toBe('FBA_APPOINTMENT:1')
|
||||
scope.vm.$emit('updated'); await flushPromises()
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(getWorkDetail).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkDetail).toHaveBeenLastCalledWith('work-delivery-1', expect.any(AbortSignal))
|
||||
expect(wrapper.findComponent({ name: 'WorkReceivingPanel' }).exists()).toBe(true)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
scope.vm.$emit('appointment', 'AP-LATER'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-LATER')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('resolves the saved later batch and offers its real vehicle next action instead of refreshing the historical work', async () => {
|
||||
const edit: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: '1', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
|
||||
const original = workDetail({ item: workItem({ allowedActions: [edit], nextAction: edit }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-OLD', label: '预约' }] })
|
||||
const wrapper = render(original, edit); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
|
||||
wrapper.getComponent({ name: 'WorkReceivingPanel' }).vm.$emit('appointment', 'AP-LATER-40'); await flushPromises()
|
||||
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'real-transport-later', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:40'] } }
|
||||
const childDetail = workDetail({ item: workItem({ workItemKey: 'real-work-child', batchKey: 'FBA_APPOINTMENT:40', nextAction: transport, allowedActions: [transport] }) })
|
||||
vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: childDetail, candidates: [] })
|
||||
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('saved', batchContext('AP-LATER-40', 'FBA_APPOINTMENT:40').appointment); await flushPromises()
|
||||
expect(resolveBatchWork).toHaveBeenCalledWith('FBA_APPOINTMENT:40', undefined, expect.any(AbortSignal))
|
||||
expect(getWorkDetail).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.emitted('open')).toEqual([['real-work-child']]); wrapper.unmount()
|
||||
const current = render(childDetail); await flushPromises()
|
||||
expect(current.get('.primary-next-action').text()).toBe('继续安排本批车辆')
|
||||
await current.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(current.emitted('open')).toEqual([['real-transport-later', transport]]); current.unmount()
|
||||
})
|
||||
it('displays all associated containers of a shared trip without creating duplicate transport work', async () => {
|
||||
const wrapper = render(workDetail({ item: workItem({ type: 'TRANSPORT', containers: [{ containerBookingId: '1', bookingNo: 'D-1', containerNo: 'TEST-CN-A' }, { containerBookingId: '2', bookingNo: 'D-2', containerNo: 'TEST-CN-B' }] }) })); await flushPromises()
|
||||
expect(wrapper.findAll('.container-links button')).toHaveLength(2)
|
||||
|
||||
@@ -4,7 +4,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getWorkDetail, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandResult } from './workApi'
|
||||
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason } from './workPresentation'
|
||||
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason, sameAction } from './workPresentation'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
@@ -13,17 +13,35 @@ import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import WorkReceiptPlans from './WorkReceiptPlans.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
|
||||
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
|
||||
const attentionReasons = computed(() => [...new Set([
|
||||
...(detail.value?.item.blockReasons || []),
|
||||
...(detail.value?.item.missingFields || []),
|
||||
...(detail.value?.item.overdueReasons || []),
|
||||
].map(workReason))])
|
||||
const needsFollowup = computed(() => !!detail.value && detail.value.item.executionStatus !== 'COMPLETED' && (!detail.value.item.owner || !detail.value.item.followupDate))
|
||||
const historyLabels: Record<string, string> = { BASELINE_OBSERVED: '同步原业务状态', GENERATED: '创建工作事项', CONFIRM_ROUTE: '确认路线', SAVE_FOLLOWUP: '记录跟进', RESOLVE_REVIEW: '处理复核', SAVE_REQUIREMENT: '保存预约要求', HANDOFF_RETURN: '交接还空责任', ACCEPT_RETURN: '确认接手还空' }
|
||||
function historyTime(value: string) { return value.replace('T', ' ').replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/, '').replace(/Z$/, ' UTC') }
|
||||
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
|
||||
const actionNotice = ref('')
|
||||
const saveCompleted = ref(false)
|
||||
const creatingFromCargo = ref(false)
|
||||
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
|
||||
const receivingKey = ref<string | null>(null)
|
||||
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
|
||||
let controller: AbortController | null = null, request = 0
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load() {
|
||||
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
|
||||
try { const result = await getWorkDetail(props.itemKey, controller.signal); if (generation === request) detail.value = result }
|
||||
const signal = controller.signal
|
||||
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
|
||||
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
|
||||
finally { if (generation === request) loading.value = false }
|
||||
}
|
||||
@@ -49,24 +67,40 @@ const existingDispatches = computed(() => {
|
||||
return /^DISPATCH:\d+$/.test(batchKey) && item?.batchKey === batchKey && item.batchNo ? [{ batchKey, dispatchNo: item.batchNo }] : []
|
||||
})
|
||||
const canAct = computed(() => !!detail.value && !!action.value && actionAllowed(detail.value.item, action.value) && !error.value && !loading.value)
|
||||
const secondaryActions = computed(() => detail.value?.item.allowedActions.filter(candidate => candidate.code !== action.value?.code && (action.value || !detail.value?.item.nextAction || !sameAction(candidate, detail.value.item.nextAction))) || [])
|
||||
async function choose(next: WorkAction) {
|
||||
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
|
||||
actionNotice.value = ''
|
||||
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
|
||||
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
|
||||
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
|
||||
saveCompleted.value = false
|
||||
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
|
||||
const path = actionRoute(next, detail.value.references, detail.value.item)
|
||||
if (path) { emit('external', path); return }
|
||||
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; creatingFromCargo.value = false
|
||||
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; continuation.value = null; creatingFromCargo.value = false
|
||||
if (next.code === 'EDIT_BATCH' && next.target.kind === 'FBA_APPOINTMENT' && next.target.batchKey) { batchKey.value = next.target.batchKey; return }
|
||||
if (next.target.kind === 'WORK_COMMAND' && !commandName(next)) actionNotice.value = '当前操作尚未提供可用入口,请联系负责人核对。'
|
||||
if (next.target.kind === 'FBA_APPOINTMENT' && !fbaNumber.value && !createSources.value) actionNotice.value = '尚未关联可操作的预约记录,请先核实批次与预约关系。'
|
||||
if (next.target.kind === 'FLEET_TRIP' && !dispatchBatchKeys.value?.length && !existingDispatches.value.length) actionNotice.value = '尚未关联可进入的车次或可排批次,请核对运输来源。'
|
||||
}
|
||||
function saved(result?: WorkCommandResult, command?: WorkCommand['command']) { emit('saved', result, command) }
|
||||
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
|
||||
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; creatingFromCargo.value = false; actionNotice.value = ''; saveCompleted.value = true
|
||||
emit('saved', result, command)
|
||||
await load()
|
||||
}
|
||||
async function savedAppointment(appointment: FbaAppointmentDto) {
|
||||
if (!appointment.batchKey) { await saved(); return }
|
||||
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; saveCompleted.value = true
|
||||
continuation.value = { batchKey: appointment.batchKey, labels: [...new Set(appointment.items.map(item => item.customerId))].map(customerId => ({ customerId, label: appointment.items.filter(item => item.customerId === customerId).map(item => item.inboundCode || '—').join('、') })) }
|
||||
emit('updated')
|
||||
}
|
||||
async function receivingUpdated() { emit('updated'); await load() }
|
||||
async function cargoSaved() { emit('updated'); await load() }
|
||||
async function createFromCargo() { if (canAct.value && await props.canLeave()) creatingFromCargo.value = true }
|
||||
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null } }
|
||||
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null } }
|
||||
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null; receivingKey.value = null } }
|
||||
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null; receivingKey.value = null } }
|
||||
async function openReceiving(key: string) { if (await props.canLeave()) { receivingKey.value = key; batchKey.value = null; openedAppointmentNo.value = null } }
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
// A predecessor's action is only a hint: use the newly fetched item's own qualification.
|
||||
@@ -79,38 +113,87 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-busy="loading">
|
||||
<div class="work-detail" :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取当前事项与操作资格…</p>
|
||||
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
|
||||
<template v-if="detail">
|
||||
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
|
||||
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
|
||||
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
|
||||
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
|
||||
<header class="detail-overview">
|
||||
<div class="detail-title"><h3>{{ detail.item.title }}</h3><el-tag :type="detail.item.executionStatus === 'COMPLETED' ? 'success' : detail.item.reviewRequired ? 'warning' : 'info'" size="small">{{ workStatus(detail.item) }}</el-tag></div>
|
||||
<p class="detail-category">{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }}</p>
|
||||
</header>
|
||||
<dl class="density-fields detail-facts" aria-label="事项关键信息">
|
||||
<div><dt>作业时间</dt><dd>{{ workDate(detail.item) }}</dd></div>
|
||||
<div><dt>作业时区</dt><dd>{{ detail.item.timezone || '地点时区待核实' }}</dd></div>
|
||||
<div><dt>作业地点</dt><dd>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }}</dd></div>
|
||||
<div><dt>负责人</dt><dd>{{ detail.item.owner?.name || '待分派' }}</dd></div>
|
||||
<div class="followup-summary"><dt>下次跟进</dt><dd>{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'" class="field-note">作业日期未定,跟进日不作为作业日期</span></dd></div>
|
||||
</dl>
|
||||
<div v-if="detail.item.containers.length" class="container-links"><span>关联货柜</span><el-button v-for="container in detail.item.containers" :key="container.containerBookingId" class="density-action" data-tone="primary" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</el-button></div>
|
||||
<aside v-if="needsFollowup || attentionReasons.length" class="attention-panel" aria-label="事项提醒">
|
||||
<h4>事项提醒</h4>
|
||||
<ul>
|
||||
<li v-if="needsFollowup">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</li>
|
||||
<li v-for="reason in attentionReasons" :key="reason">{{ reason }}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
|
||||
<template v-if="!action">
|
||||
<el-button v-if="detail.item.nextAction" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
|
||||
<section class="current-work" aria-label="当前处理">
|
||||
<h4>{{ detail.item.executionStatus === 'COMPLETED' ? '事项结果' : '当前处理' }}</h4>
|
||||
<p v-if="detail.item.executionStatus === 'COMPLETED'" class="completion-summary">当前事项已完成,可查看关联工作和历史记录。</p>
|
||||
<template v-if="!action && !continuation">
|
||||
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
|
||||
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
|
||||
<p v-else-if="detail.item.executionStatus !== 'COMPLETED'">当前没有可执行的下一步,请根据缺项或前置说明联系负责人。</p>
|
||||
</template>
|
||||
<p v-if="actionNotice" role="status" class="work-warning">{{ actionNotice }}</p>
|
||||
<AppointmentBatchForm v-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="saved()" @batch="openBatch" />
|
||||
<WorkBatchContinuation v-if="continuation" :key="JSON.stringify(continuation)" v-bind="continuation" @open="key => emit('open', key)" />
|
||||
<WorkReceivingPanel v-else-if="receivingKey" :key="receivingKey" :batch-key="receivingKey" :owners="receivingOwners" :can-write="!loading && !error" @updated="receivingUpdated" @appointment="openAppointment" @batch="openBatch" @external="path => emit('external', path)" />
|
||||
<AppointmentBatchForm v-else-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
|
||||
<WorkCommandForm v-else-if="action && commandName(action)" :key="`${itemKey}:${action.code}`" :detail="detail" :action="action" :refreshing="loading || !!error" @refresh="load" @saved="saved" />
|
||||
<template v-else-if="cargoSource && !creatingFromCargo"><CargoVerificationForm :key="`${cargoSource.customerId}:${cargoSource.sourceKey}:${cargoSource.destinationKey}`" :source="cargoSource" @saved="cargoSaved" /><el-button :disabled="!canAct" @click="createFromCargo">核对货物并新建原预约</el-button></template>
|
||||
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="saved()" @batch="openBatch" />
|
||||
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
|
||||
<WorkCreateAppointmentForm v-else-if="createSources" :key="JSON.stringify(createSources)" :sources="createSources" :can-write="canAct" @saved="saved()" />
|
||||
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
|
||||
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<details v-if="detail.item.allowedActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in detail.item.allowedActions.filter(candidate => candidate.code !== action?.code)" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
<section v-else-if="action?.target.kind === 'READ_ONLY' && action.target.references.length" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
|
||||
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
|
||||
</section>
|
||||
<section class="detail-records" aria-label="关联资料与历史">
|
||||
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button class="density-action" @click="emit('open', related.workItemKey)">查看</el-button></div><p v-if="!detail.relatedItems.length">暂无关联工作</p></details>
|
||||
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p><p v-if="!detail.references.length">暂无原业务记录</p></details>
|
||||
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录 · {{ detail.history.length }} 条</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><div class="history-heading"><b>{{ historyLabels[entry.action] || '事项记录' }}</b><time :datetime="entry.occurredAt">{{ historyTime(entry.occurredAt) }}</time></div><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }}<span v-if="!historyLabels[entry.action]"> · {{ entry.action }}</span></small></div><p v-if="!detail.history.length">暂无记录</p></details>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
|
||||
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
.work-detail { font-size: var(--admin-font-body); line-height: 1.6; color: var(--el-text-color-primary); }
|
||||
.detail-overview { margin-bottom: var(--admin-space-5); }
|
||||
.detail-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.detail-title h3 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.detail-category { margin: var(--admin-space-1) 0 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-detail .detail-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.followup-summary { grid-column: 1 / -1; }
|
||||
.field-note { margin-left: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.container-links, .more-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.container-links { margin-top: var(--admin-space-3); }
|
||||
.container-links > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.attention-panel { margin-top: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5); border-left: 3px solid var(--el-color-warning); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); }
|
||||
.attention-panel h4, .current-work > h4 { margin: 0 0 var(--admin-space-1); font-size: var(--admin-font-section); }
|
||||
.attention-panel ul { margin: 0; padding-left: var(--admin-space-6); font-size: var(--admin-font-caption); }
|
||||
.current-work { margin-top: var(--admin-space-5); padding-block: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.current-work > p { margin: var(--admin-space-2) 0; }
|
||||
.completion-summary { color: var(--el-color-success-dark-2); }
|
||||
.detail-records { border-top: 1px solid var(--cc-border); }
|
||||
.detail-records > details { border-bottom: 1px solid var(--cc-border); }
|
||||
summary { cursor: pointer; padding-block: var(--admin-space-3); font-weight: 600; }
|
||||
summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
|
||||
.detail-records p { margin: var(--admin-space-1) 0; }
|
||||
.related-row, .history-heading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }
|
||||
.related-row:last-child, .history-entry:last-child { border-bottom: 0; }
|
||||
.related-row p, .history-entry small, .history-heading time { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-warning { color: var(--el-color-warning-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,162 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkDispatchForm from './WorkDispatchForm.vue'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
const access = vi.hoisted(() => ({ allowed: true, denied: [] as string[] }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
function render(props: { batchKeys?: string[]; canWrite?: boolean } = {}) { return mount(WorkDispatchForm, { props: { batchKeys: [batch], canWrite: true, ...props }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative allocation version %s from the existing batch provider', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
|
||||
const value = row(); value.allocations[0]!.recordVersion = recordVersion as unknown as number
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 0, candidates: [value], blockReasons: [] })
|
||||
const wrapper = render()
|
||||
try { await flushPromises(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选'); expect(createDispatchDraft).not.toHaveBeenCalled() }
|
||||
finally { wrapper.unmount() }
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.allowed = true
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([])
|
||||
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
|
||||
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
|
||||
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
|
||||
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; access.denied = []
|
||||
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
vi.mocked(convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: [{ draftContainerId: 'container-1', dispatch: proof(body.groups[0]!.dispatch.idempotencyKey) }] }))
|
||||
vi.mocked(getDispatch).mockImplementation(async no => detail(no)); vi.mocked(listDispatchDrafts).mockImplementation(async () => [clone(draft)])
|
||||
vi.mocked(releaseDispatchDraft).mockImplementation(async () => ({ ...clone(draft), status: 'RELEASED', containers: [], sources: [] }))
|
||||
})
|
||||
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
|
||||
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
|
||||
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
|
||||
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
|
||||
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
|
||||
wrapper.unmount()
|
||||
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
|
||||
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
} finally { wrapper.unmount(); get.mockRestore() }
|
||||
})
|
||||
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
|
||||
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false)
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS/EXISTING', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
|
||||
}
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('recovers a draft container from the same token when its creation response was lost', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
|
||||
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
|
||||
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
|
||||
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
|
||||
vi.mocked(getReceivingContext).mockResolvedValueOnce(receiving(batch, [{ dispatchNo: 'DS-OLD', batchKey: batch, customerId: 'customer-b', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const mismatch = render(); await flushPromises(); expect(mismatch.text()).toContain('排车单与工作批次关联不一致'); expect(mismatch.text()).not.toContain('核对本批剩余可排实物'); expect(createDispatchDraft).not.toHaveBeenCalled(); mismatch.unmount()
|
||||
})
|
||||
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b' }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
|
||||
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
|
||||
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
|
||||
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
|
||||
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
|
||||
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
|
||||
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
|
||||
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
|
||||
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('does not read or write scoped cargo without all original permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises()
|
||||
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
|
||||
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
|
||||
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
|
||||
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
|
||||
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
|
||||
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
|
||||
})
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
})
|
||||
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
|
||||
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
|
||||
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
|
||||
})
|
||||
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('fails closed on incomplete native candidates, 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()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
|
||||
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
|
||||
expect(await guard()).toBe(false); wrapper.unmount()
|
||||
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
|
||||
})
|
||||
it('retries the exact container command after a lost response instead of generating another container key', async () => {
|
||||
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
|
||||
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
|
||||
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
|
||||
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
|
||||
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
|
||||
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
|
||||
})
|
||||
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
|
||||
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
|
||||
})
|
||||
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
|
||||
access.allowed = false; const denied = render(); await flushPromises(); expect(getReceivingContext).not.toHaveBeenCalled(); denied.unmount()
|
||||
access.allowed = true; access.denied = ['platform:overseas-warehouse:dispatch:create']; const readonly = render(); await flushPromises(); expect(button(readonly, '创建整车排车单').attributes('disabled')).toBeDefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
})
|
||||
|
||||
@@ -1,92 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
||||
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
||||
type ReadyRow = BatchRow & { native: Candidate }
|
||||
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
||||
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
||||
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
||||
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
||||
function reasons(row: BatchDispatchCandidate) {
|
||||
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
||||
return [...row.blockReasons, ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
|
||||
}
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
|
||||
useWorkDraft(computed(() => selected.value.length > 0 && !result.value), computed(() => saving.value || !!pending.value))
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
let generation = 0
|
||||
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
||||
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value || pending.value) return
|
||||
loading.value = true; error.value = null
|
||||
try { contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates)) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
|
||||
finally { loading.value = false }
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
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 = []
|
||||
try {
|
||||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||||
if (current !== generation) return
|
||||
const references: typeof existing.value = []
|
||||
receiving.forEach((context, index) => {
|
||||
const batchKey = batches[index]!
|
||||
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
|
||||
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
||||
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
|
||||
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
||||
}
|
||||
})
|
||||
existing.value = references
|
||||
if (references.length && !arrangingRemaining.value) return
|
||||
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
|
||||
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
|
||||
warehouseId.value = origins[0]!
|
||||
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
|
||||
if (current !== generation) return
|
||||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||||
contexts.value = batchCandidates
|
||||
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
||||
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
||||
if (current !== generation) return
|
||||
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
||||
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
||||
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
||||
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||||
const candidate = matches.length === 1 ? matches[0]! : null
|
||||
const sources = receiving[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
||||
}))
|
||||
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)
|
||||
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||
baseline.value = JSON.stringify(selected.value)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function select(row: BatchDispatchCandidate, checked: boolean) {
|
||||
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
|
||||
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
|
||||
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
|
||||
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
|
||||
selected.value = [...selected.value, row.planPalletId]
|
||||
}
|
||||
function changeSelection(row: BatchDispatchCandidate, event: Event) {
|
||||
function select(row: BatchRow, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
|
||||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
||||
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||||
else if (!selected.value.includes(row.key)) {
|
||||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||||
else selected.value.push(row.key)
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
||||
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
||||
async function executeStep() {
|
||||
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
||||
command.attempted = true; persist()
|
||||
try {
|
||||
let value: DispatchDraft | ConvertedDraft
|
||||
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
||||
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
||||
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
||||
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
||||
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
||||
ensureOwner()
|
||||
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
||||
if (command.action === 'CONVERT') {
|
||||
const converted = value as ConvertedDraft
|
||||
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
||||
if (command.action === 'CONTAINER') {
|
||||
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
||||
request.containerId = draft.containers[0]!.draftContainerId
|
||||
}
|
||||
if (command.action === 'LOCK') {
|
||||
const selectedSource = command.body.source as DraftSelection['source']
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
request.step = null; persist()
|
||||
} catch (cause) {
|
||||
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
||||
persist(); throw cause
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!writable.value || saving.value || loading.value || error.value || result.value) return
|
||||
if (!pending.value) {
|
||||
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
|
||||
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
|
||||
}
|
||||
saving.value = true; saveError.value = null; rejected.value = false
|
||||
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
||||
saveError.value = null
|
||||
try {
|
||||
const request = pending.value
|
||||
if (!request.containerId) {
|
||||
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
|
||||
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
|
||||
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
|
||||
ensureOwner()
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
|
||||
request.submitting = true
|
||||
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
|
||||
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
|
||||
result.value = response[0].dispatch; pending.value = null; selected.value = []
|
||||
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
|
||||
saving.value = true
|
||||
const request = pending.value
|
||||
while (!request.converted) {
|
||||
if (!request.step) {
|
||||
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
||||
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
||||
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
||||
else {
|
||||
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
||||
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
await executeStep()
|
||||
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
||||
}
|
||||
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
||||
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
||||
ensureOwner(); validateCurrentDispatch(latest, proof)
|
||||
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
||||
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function refreshRejected() {
|
||||
if (!rejected.value || !pending.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
|
||||
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
|
||||
async function releaseRejected() {
|
||||
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
ensureOwner(); const request = pending.value
|
||||
if (!request.draft) { pending.value = null; persist(); return }
|
||||
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
||||
ensureOwner()
|
||||
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
||||
request.draft = owned; request.releasing = true; request.step = null; persist()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
||||
finally { saving.value = false }
|
||||
if (pending.value?.releasing) await save()
|
||||
else if (!pending.value) await load()
|
||||
}
|
||||
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
async function continueArranging() { if (!pending.value && !saving.value) { result.value = null; await load() } }
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次排车">
|
||||
<h3>按批次选择真实托盘</h3><p>每次选择同一客户的实物创建排车单,再安排车辆和司机;逐来源数量由服务端候选确定。</p>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
||||
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" @click="prepareOpen = true">安排车次、车辆与司机</el-button><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button @click="continueArranging">继续安排其他实物</el-button></template>
|
||||
<template v-else-if="readable"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
||||
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
<template v-if="existing.length && !loading && !error && !result && !pending">
|
||||
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
||||
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
||||
</template>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
|
||||
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
|
||||
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
||||
import { getDispatch } from '@/modules/overseas-warehouse'
|
||||
const state = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => state.allowed }) }))
|
||||
import { getDispatch, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
function render() { return mount(WorkExistingDispatchForm, { props: { dispatches: [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }], canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) })
|
||||
it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL')
|
||||
await wrapper.findAll('button').find(button => button.text() === '安排原排车单车次')!.trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] })
|
||||
dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||
it.each(['customer-b', undefined])('rejects a FBA reference outside the exact batch customer: %s', async customerId => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', recordVersion: 0, sources: [] }]; vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render([{ batchKey: 'FBA_APPOINTMENT:1', dispatchNo: value.dispatchNo, customerId }]); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE' })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount()
|
||||
it('reads a shared whole dispatch once and verifies every nested batch and real customer', async () => {
|
||||
const value = detail(); value.dispatchInfo.appointmentBatches = ['a', 'b'].map((customerId, index) => ({ batchKey: `FBA_APPOINTMENT:${index + 1}`, batchNo: `AP-${index + 1}`, customerId, recordVersion: 0, sources: [] })); vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
const wrapper = render(value.dispatchInfo.appointmentBatches.map(row => ({ batchKey: row.batchKey, customerId: row.customerId, dispatchNo: value.dispatchNo }))); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.findAll('tbody tr')).toHaveLength(1); expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('opens an already bound real trip instead of preparing another one', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('安排原排车单车次')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount()
|
||||
it('resolves the opaque dispatch batch in the work domain and opens the original independent detail', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(resolveBatchWork).toHaveBeenCalledWith('DISPATCH:9007199254740999', undefined)
|
||||
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FORIGINAL']])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999'); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
|
||||
})
|
||||
it('does not read the dispatch without its original list permission', async () => {
|
||||
state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
it('rejects mismatched authority or returned business identity instead of deriving an internal ID', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'DISPATCH:9007199254740999', customerId: 'a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-OTHER' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.find('table').exists()).toBe(false); expect(wrapper.text()).toContain('关联不一致'); wrapper.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.find('table').exists()).toBe(false); wrong.unmount()
|
||||
})
|
||||
it('opens same-dispatch adding only under the server gate, and keeps already scanned cargo out of the old merge flow', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '为本车添加同仓货物').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props()).toMatchObject({ dispatchNo: 'DS/ORIGINAL', canWrite: true }); wrapper.unmount()
|
||||
const value = detail(); value.stage = 'LOADING'; value.counts.pickedPackageCount = 1; value.actions.ADD_CARGO!.allowed = false; vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const scanned = render(); await flushPromises(); expect(scanned.text()).not.toContain('为本车添加同仓货物'); expect(scanned.text()).not.toContain('共车'); scanned.unmount()
|
||||
})
|
||||
it('does not read dispatch or batch authority without the actual list permissions', async () => {
|
||||
access.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); expect(resolveBatchWork).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,38 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
||||
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
||||
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchSummaryDto[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const writable = computed(() => props.canWrite && readable.value && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit'))
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
const props = defineProps<{ dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }>; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; external: [path: string]; ready: [valid: boolean] }>()
|
||||
const access = useAccessPreviewStore(), rows = ref<DispatchDetail[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const addingNo = ref(''), addingBusy = ref(false)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list') && (!props.dispatches.some(row => row.batchKey.startsWith('DISPATCH:')) || access.can('platform:appointment:list')))
|
||||
function path(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
function canEdit(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.actions.EDIT?.allowed === true }
|
||||
function canAdd(row: DispatchDetail) { return props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && row.status === 'ACTIVE' && row.stage === 'BEFORE_LOADING' && row.actions.ADD_CARGO?.allowed === true }
|
||||
let generation = 0
|
||||
async function load() {
|
||||
const current = ++generation; emit('ready', false); rows.value = []; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true; error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await Promise.all(props.dispatches.map(async reference => {
|
||||
const row = await getDispatch(reference.dispatchNo)
|
||||
if (`DISPATCH:${row.id}` !== reference.batchKey || row.dispatchNo !== reference.dispatchNo) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
const references = props.dispatches.map(row => ({ ...row }))
|
||||
const details = await Promise.all([...new Set(references.map(row => row.dispatchNo))].map(async no => {
|
||||
const row = await getDispatch(no)
|
||||
if (row.dispatchNo !== no || row.status === 'CANCELLED') throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
for (const reference of references.filter(value => value.dispatchNo === no)) {
|
||||
let matches = false
|
||||
if (/^FBA_APPOINTMENT:\d+$/.test(reference.batchKey)) {
|
||||
matches = Boolean(reference.customerId && row.dispatchInfo.appointmentBatches.some(batch => batch.batchKey === reference.batchKey && batch.customerId === reference.customerId))
|
||||
} else if (/^DISPATCH:\d+$/.test(reference.batchKey)) {
|
||||
// The work domain resolves its opaque batch identity; Detail has no internal dispatch ID.
|
||||
const resolution = await resolveBatchWork(reference.batchKey, reference.customerId)
|
||||
matches = resolution.batchKey === reference.batchKey && (resolution.detail
|
||||
? resolution.detail.item.batchKey === reference.batchKey && resolution.detail.item.batchNo === no && (!reference.customerId || resolution.customerId === reference.customerId)
|
||||
: resolution.candidates.length > 0 && resolution.candidates.every(candidate => candidate.batchNo === no) && (!reference.customerId || resolution.candidates.some(candidate => candidate.customerId === reference.customerId)))
|
||||
}
|
||||
if (!matches) throw new ApiResponseError('排车单与工作批次关联不一致,请刷新核对', 'WORK_DISPATCH_MISMATCH')
|
||||
}
|
||||
return row
|
||||
}))
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { loading.value = false }
|
||||
if (current !== generation) return
|
||||
rows.value = details; emit('ready', details.length > 0)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '原排车单读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare(row: DispatchSummaryDto) { if (writable.value && !loading.value && !error.value && row.status === 'PENDING_DEPARTURE' && !row.workflow && row.overseasWarehouseId) { selected.value = row; prepareOpen.value = true } }
|
||||
function saved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
||||
onMounted(load)
|
||||
function add(row: DispatchDetail) { if (canAdd(row) && !loading.value && !error.value && !addingBusy.value) addingNo.value = row.dispatchNo }
|
||||
function updated() { emit('updated'); void load() }
|
||||
watch(() => props.dispatches, load, { immediate: true, deep: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="原排车单安排用车"><h3>原排车单安排用车</h3><p>客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。</p>
|
||||
<p v-if="!readable">查看原排车单需要排车列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>起运仓</th><th>箱数</th><th>车次</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="!row.workflow" :disabled="!writable || row.status !== 'PENDING_DEPARTURE' || !row.overseasWarehouseId" @click="prepare(row)">安排原排车单车次</el-button><el-button @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(row.dispatchNo)}`)">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.overseasWarehouseName || '起运仓待核实' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td><el-button v-if="row.workflow?.tripNo && access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(row.workflow.tripNo)}`)">{{ row.workflow.tripNo }}</el-button><span v-else>尚未关联车次</span></td></tr></tbody></table>
|
||||
<TripPreparationDialog v-if="selected?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="selected.overseasWarehouseId" :warehouse-name="selected.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[selected.dispatchNo]" @saved="saved" />
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
||||
<WorkSharedTripForm v-if="addingNo" :key="addingNo" :dispatch-no="addingNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="updated" @external="value => emit('external', value)" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -15,9 +15,10 @@ beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContai
|
||||
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
const plans = wrapper.findAll('button').filter(item => item.text() === '查看原接仓记录')
|
||||
await plans[0]!.trigger('click')
|
||||
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
|
||||
expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
|
||||
expect(plans[1]!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
|
||||
})
|
||||
it('does not read the container source without its original permission', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -42,7 +43,7 @@ async function load() { if (!readable.value) return; loading.value = true; error
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划</h3><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
|
||||
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划<InlineHelp title="接仓计划说明"><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p></InlineHelp></h3><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="本柜入仓计划读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
|
||||
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { createAppointmentForm, appointmentPayload } from '@/modules/overseas-warehouse'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import * as api from './receivingApi'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ denied: [] as string[] }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner-current', name: '当前本人' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !access.denied.includes(permission) }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn(), getReceivingGroup: vi.fn(), proposeReceivingChange: vi.fn(), resolveReceivingChange: vi.fn(), bookReceiving: vi.fn(), changeReceivingGroup: vi.fn(), refreshReceivingCargo: vi.fn(), saveReceivingPlan: vi.fn(), getReceivingExecutionScope: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
const detail = () => appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-OLD', appointmentAt: '2026-10-20T10:00:00' })
|
||||
function context(): api.ReceivingContext {
|
||||
const batch = batchContext().appointment
|
||||
batch.items = batch.items.map(item => ({ ...item, sourceVersion: 'source-v3', cargoVerificationVersion: 2 }))
|
||||
return { batch, executionScope: { version: 'execution-v1', members: batch.items.map(item => ({ member: { batchKey: batch.batchKey!, customerId: item.customerId }, completed: false, canRebook: true, reason: null })) }, agreements: [], pendingChanges: [], plans: [], allowedActions: ['PROPOSE_CUSTOMER_CHANGE', 'BOOK_RECEIVING', 'PLAN_RECEIVING', 'REFRESH_CARGO'], blockReasons: [], transport: { version: 'transport-v1', vehicles: [], blockReasons: [] } }
|
||||
}
|
||||
function members(): api.ReceivingMember[] { const c = context(); return c.batch.items.map(item => ({ batchKey: c.batch.batchKey!, appointmentNo: c.batch.appointmentNo, batchVersion: c.batch.version, customerId: item.customerId, customerName: null, sources: [{ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion! }] })) }
|
||||
function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreementKey: 'GROUP-1', version: 4, status, destinationKey: 'FBA:ONT8', details: detail(), timezone: 'America/Los_Angeles', members: members(), warehouseEvidence: '原仓库确认' } }
|
||||
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
|
||||
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
|
||||
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
|
||||
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
|
||||
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
|
||||
} } }) }
|
||||
function button(wrapper: ReturnType<typeof render>, label: string) { return wrapper.findAll('button').find(row => row.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); access.denied = []; vi.mocked(api.getReceivingExecutionScope).mockImplementation(async members => ({ version: 'execution-v1', members: members.map(member => ({ member, completed: false, canRebook: true, reason: null })) })); vi.mocked(api.getReceivingContext).mockResolvedValue(context()); vi.mocked(api.proposeReceivingChange).mockResolvedValue(result('WAITING')); vi.mocked(api.resolveReceivingChange).mockResolvedValue(result()); vi.mocked(api.bookReceiving).mockResolvedValue(result()); vi.mocked(api.refreshReceivingCargo).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.saveReceivingPlan).mockResolvedValue({ ...result(), steps: [], nextActions: [] }); vi.mocked(api.changeReceivingGroup).mockResolvedValue(result()); vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: agreement(), eligibleMembers: members(), excludedMembers: [], transport: context().transport, executionScope: context().executionScope }) })
|
||||
async function prepareAdjustment(wrapper: ReturnType<typeof render>, quantity = 0) {
|
||||
await flushPromises(); await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity)
|
||||
await wrapper.get('[aria-label="调整负责人"]').setValue('owner-related')
|
||||
await wrapper.get('[aria-label="调整原因"]').setValue('本客户后送')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
}
|
||||
it('allows zero retained quantity for one customer and only persists a waiting change with all original versions', async () => {
|
||||
const wrapper = render(); await prepareAdjustment(wrapper)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', ownerId: 'owner-related', laterDate: null, transportVersion: 'transport-v1', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }] }) }))
|
||||
expect(wrapper.text()).toContain('原安排尚未改变'); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('rejects unchanged, fractional or excessive retained quantities without creating empty later batches', async () => {
|
||||
const wrapper = render(); await prepareAdjustment(wrapper, 70)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises(); expect(wrapper.text()).toContain('本次先送量未改变')
|
||||
for (const quantity of [71, -1, 0.5]) { await wrapper.get('[aria-label="本次先送箱数"]').setValue(quantity); await wrapper.get('form').trigger('submit'); await flushPromises() }
|
||||
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('locks unknown outcomes and retries the identical change command without applying or recreating cargo', async () => {
|
||||
vi.mocked(api.proposeReceivingChange).mockRejectedValueOnce(new Error('network timeout'))
|
||||
const wrapper = render(); await prepareAdjustment(wrapper, 30); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(api.proposeReceivingChange).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.proposeReceivingChange).toHaveBeenNthCalledWith(2, first); expect(api.resolveReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('resumes persisted waiting input, requires warehouse evidence and sends the current change and transport versions', async () => {
|
||||
const c = context(); c.pendingChanges = [pendingChange()]; c.transport.version = 'transport-v2'; c.allowedActions = []
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('本次先送 0 箱'); expect(wrapper.text()).toContain('EXT-OLD')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.resolveReceivingChange).not.toHaveBeenCalled()
|
||||
await wrapper.get('[aria-label="仓库回复依据"]').setValue('仓库同意该客户全部后送,其他客户确认保留')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'CONFIRM', version: 1, transportVersion: 'transport-v2' }))
|
||||
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('withdraws only the persisted change without claiming a cancellation or requiring confirmation evidence', async () => {
|
||||
const c = context(); c.pendingChanges = [pendingChange()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.resolveReceivingChange).mockResolvedValue(result('WITHDRAWN'))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '继续本次变化').trigger('click'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await button(wrapper, '撤回变化并保留原安排').trigger('click'); await flushPromises()
|
||||
expect(api.resolveReceivingChange).toHaveBeenCalledWith('CHANGE-1', expect.objectContaining({ decision: 'WITHDRAW', warehouseEvidence: null }))
|
||||
expect(wrapper.text()).toContain('已撤回变化,原安排保留'); wrapper.unmount()
|
||||
})
|
||||
it('rebooks only eligible cancelled-group members and submits one shared booking with a new command identity', async () => {
|
||||
const c = context(); c.agreements = [agreement('CANCELLED')]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [members()[0]!], excludedMembers: [{ member: members()[1]!, reason: '已另行预约', currentAgreementKey: 'GROUP-OTHER' }], transport: c.transport, executionScope: c.executionScope })
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已另行预约'); expect(wrapper.text()).not.toContain('GROUP-OTHER')
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'EXT-NEW', appointmentAt: '2026-10-23T11:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仓库确认当前剩余客户货量'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
|
||||
expect(request.cancelledAgreementKey).toBe('GROUP-1'); expect(request.cancelledAgreementVersion).toBe(4)
|
||||
expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a' }])
|
||||
expect(request.bookings[0]!.details.confirmationNo).toBe('EXT-NEW'); wrapper.unmount()
|
||||
})
|
||||
it('preserves per-customer booking input on an old-number conflict and requires renewed scope confirmation', async () => {
|
||||
vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('当前目的仓约号已使用', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
Object.assign(model, { confirmationNo: 'EXT-IN-USE', appointmentAt: '2026-10-23T11:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('明确覆盖'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(model.confirmationNo).toBe('EXT-IN-USE'); expect(wrapper.text()).toContain('当前目的仓约号已使用')
|
||||
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires explicit whole-scope cancellation confirmation and uses the group version and delete permission', async () => {
|
||||
const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
access.denied = ['platform:overseas-warehouse:inbound-order:fba-appointment']
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份取消范围').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="整份回复依据"]').setValue('仓库确认整份取消'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||
expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'CANCEL', version: 4, details: null, transportVersion: 'transport-v1' })); wrapper.unmount()
|
||||
})
|
||||
it('keeps a successful result when refresh fails and retries only reads', async () => {
|
||||
const wrapper = render(); await prepareAdjustment(wrapper)
|
||||
vi.mocked(api.getReceivingContext).mockRejectedValueOnce(new Error('读取暂不可用'))
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原安排尚未改变'); expect(wrapper.find('form').exists()).toBe(false)
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(api.proposeReceivingChange).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('does not expose internal customer IDs or replace unknown cargo with zero or permit read-only writes', async () => {
|
||||
const c = context(); c.batch.items[0]!.packageCount = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('customer-a'); expect(wrapper.text()).toContain('箱数待核实')
|
||||
await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物数量或来源资料待核实'); expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
|
||||
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
|
||||
await wrapper.setProps({ canWrite: false }); await wrapper.get('form').trigger('submit'); expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('sends independent confirmations as distinct bookings while preserving original batch identities', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true)
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `EXT-${index}`, appointmentAt: `2026-10-${22 + index}T10:00:00` }))
|
||||
for (const textarea of wrapper.findAll('[aria-label="预约覆盖依据"]')) await textarea.setValue('本客户仓库明确确认')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
|
||||
expect(request.bookings.map(row => row.members)).toEqual(members().map(({ batchKey, batchVersion, customerId }) => [{ batchKey, batchVersion, customerId }]))
|
||||
expect(request.bookings.map(row => row.details.confirmationNo)).toEqual(['EXT-0', 'EXT-1'])
|
||||
expect(request.cancelledAgreementKey).toBeNull(); wrapper.unmount()
|
||||
})
|
||||
it('does not apply existing booking inputs to different members after a range conflict', async () => {
|
||||
const original = context(); original.agreements = [agreement('CANCELLED')]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(original)
|
||||
vi.mocked(api.getReceivingGroup).mockResolvedValueOnce({ agreement: original.agreements[0]!, eligibleMembers: members(), excludedMembers: [], transport: original.transport, executionScope: original.executionScope }).mockResolvedValue({ agreement: original.agreements[0]!, eligibleMembers: [members()[1]!], excludedMembers: [{ member: members()[0]!, reason: '已另行预约', currentAgreementKey: 'OTHER' }], transport: original.transport, executionScope: original.executionScope })
|
||||
vi.mocked(api.bookReceiving).mockRejectedValueOnce(new ApiResponseError('范围版本变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'OLD-DRAFT', appointmentAt: '2026-10-22T10:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('原范围依据'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原输入不会自动套用给其他客户')
|
||||
expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
|
||||
expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
|
||||
expect(api.bookReceiving).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('treats unchanged whole-group rescheduling as unchanged rather than a new successful mutation', async () => {
|
||||
const c = context(); c.agreements = [agreement()]; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.changeReceivingGroup).mockResolvedValue({ ...result('UNCHANGED'), steps: [] })
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '核对整份改期范围').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('EXT-OLD')
|
||||
await wrapper.get('[aria-label="整份回复依据"]').setValue('核对无变化'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.changeReceivingGroup).toHaveBeenCalledWith('GROUP-1', expect.objectContaining({ action: 'RESCHEDULE', details: detail() }))
|
||||
expect(wrapper.text()).toContain('安排没有变化'); expect(wrapper.text()).not.toContain('已保存真实预约'); wrapper.unmount()
|
||||
})
|
||||
it('uses only the eligible B/D cross-batch transport scope after A booked separately', async () => {
|
||||
const a = { ...members()[0]!, batchKey: 'FBA_APPOINTMENT:10', appointmentNo: 'AP-A' }
|
||||
const b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:20', appointmentNo: 'AP-B' }
|
||||
const d = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:30', appointmentNo: 'AP-D', customerId: 'customer-d' }
|
||||
const cancelled = { ...agreement('CANCELLED'), members: [a, b, d] }
|
||||
const c = context(); c.agreements = [cancelled]; c.transport.version = 'old-A-B-D'
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: cancelled, eligibleMembers: [b, d], excludedMembers: [{ member: a, reason: '已另行预约', currentAgreementKey: 'OTHER-A' }], transport: { version: 'current-B-D', vehicles: [], blockReasons: ['无关客户已完成运输'] }, executionScope: { version: 'execution-B-D', members: [b, d].map(member => ({ member, completed: false, canRebook: true, reason: null })) } })
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'NEW-B-D', appointmentAt: '2026-10-22T10:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅覆盖B/D'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
|
||||
expect(api.getReceivingExecutionScope).toHaveBeenCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }, { batchKey: d.batchKey, customerId: d.customerId }]); expect(request.executionVersion).toBe('execution-v1'); expect(request).not.toHaveProperty('transportVersion')
|
||||
expect(request.bookings[0]!.members.map(member => member.batchKey)).toEqual(['FBA_APPOINTMENT:20', 'FBA_APPOINTMENT:30'])
|
||||
expect(request.bookings[0]!.members.some(member => member.customerId === a.customerId)).toBe(false); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses verified owner candidates and renders authoritative pending followup names instead of account IDs', async () => {
|
||||
const c = context(); c.pendingChanges = [pendingChange()]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('负责人 已核实负责人'); expect(wrapper.text()).not.toContain('owner-related')
|
||||
await button(wrapper, '调整本客户先送 / 后送').trigger('click'); await flushPromises()
|
||||
const owner = wrapper.get('[aria-label="调整负责人"]')
|
||||
expect(owner.element.tagName).toBe('SELECT')
|
||||
expect(owner.findAll('option').map(option => option.text())).toEqual(['请选择负责人', '关联负责人', '当前本人'])
|
||||
await wrapper.get('[aria-label="本次先送箱数"]').setValue(0)
|
||||
await owner.setValue('not-an-account'); await wrapper.get('[aria-label="调整原因"]').setValue('后送')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择有效负责人')
|
||||
await owner.setValue('owner-current'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.proposeReceivingChange).toHaveBeenCalledWith(expect.objectContaining({ intent: expect.objectContaining({ ownerId: 'owner-current' }) }))
|
||||
expect(vi.mocked(api.proposeReceivingChange).mock.calls[0]![0].intent).not.toHaveProperty('owner')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('books unfinished B using execution eligibility even when completed A blocks the shared transport and lacks cargo details', async () => {
|
||||
const c = context()
|
||||
c.executionScope.members[0] = { member: c.executionScope.members[0]!.member, completed: true, canRebook: false, reason: '本客户已完成,不再重约' }
|
||||
c.batch.items[0]!.packageCount = null
|
||||
c.transport.blockReasons = ['已完成客户禁止调整车辆']
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('本客户已完成,不再重约'); expect(wrapper.text()).not.toContain('货物数量或来源资料待核实')
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'ONLY-B', appointmentAt: '2026-10-23T11:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅当前未完成客户'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const command = vi.mocked(api.bookReceiving).mock.calls[0]![0]
|
||||
expect(command.executionVersion).toBe('execution-v1'); expect(command).not.toHaveProperty('transportVersion')
|
||||
expect(command.bookings[0]!.members.map(member => member.customerId)).toEqual(['customer-b'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not offer members missing execution eligibility or marked completed even if canRebook is true', async () => {
|
||||
const c = context(); c.executionScope.members = [{ ...c.executionScope.members[0]!, completed: true, canRebook: true }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-booking-fields]').exists()).toBe(false)
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
function mixedPlans(): api.ReceivingContext {
|
||||
const c = context()
|
||||
c.batch.plannedDate = '2026-10-20'; c.batch.confirmationNo = 'A-CONFIRMED'; c.batch.appointmentStatus = 'SUCCESS'
|
||||
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
|
||||
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
|
||||
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
|
||||
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
|
||||
return c
|
||||
}
|
||||
async function enterBPlan(wrapper: ReturnType<typeof render>) {
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成')
|
||||
expect(buttons[0]!.attributes('disabled')).toBeDefined()
|
||||
await buttons[1]!.trigger('click'); await flushPromises()
|
||||
}
|
||||
it('saves only B pending plan in the same stable batch where A is independently confirmed and D is still unbooked', async () => {
|
||||
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-24')
|
||||
expect(wrapper.findAll('[aria-label="客户计划状态"] option').map(row => row.attributes('value'))).toEqual(['PENDING', 'FAILED'])
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-26'); await wrapper.get('[aria-label="客户计划备注"]').setValue('乙等待回复')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: c.batch.batchKey, batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: '2026-10-26', appointmentRemark: '乙等待回复', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.changeReceivingGroup).not.toHaveBeenCalled()
|
||||
expect(c.agreements[0]!.details.confirmationNo).toBe('EXT-OLD'); expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a failed-plan reason and allows an explicitly cleared date without inheriting A or the old header date', async () => {
|
||||
const c = mixedPlans(); c.plans[0]!.details.plannedDate = null; vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
||||
await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED'); await wrapper.get('[aria-label="客户计划备注"]').setValue('')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请填写未约成的原因')
|
||||
await wrapper.get('[aria-label="客户计划备注"]').setValue('仓库暂未提供时段'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentStatus: 'FAILED', appointmentRemark: '仓库暂未提供时段', confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps customer plan drafts after a version conflict and resubmits explicitly with current batch and execution versions', async () => {
|
||||
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('批次版本变化', 'A0409', 409))
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-28'); await wrapper.get('[aria-label="客户计划备注"]').setValue('保留乙的输入')
|
||||
const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'execution-v2'; fresh.plans[0]!.details.plannedDate = '2026-10-25'
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(fresh)
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0]
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-28')
|
||||
expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const second = vi.mocked(api.saveReceivingPlan).mock.calls[1]![0]
|
||||
expect(second.details).toEqual(first.details); expect(second.member.batchVersion).toBe(4); expect(second.executionVersion).toBe('execution-v2'); expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries the identical unknown customer plan and shows unchanged without creating a confirmation', async () => {
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
|
||||
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new Error('timeout')).mockResolvedValueOnce({ ...result('UNCHANGED'), steps: [], nextActions: [] })
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(api.saveReceivingPlan).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenNthCalledWith(2, first); expect(wrapper.text()).toContain('安排没有变化'); expect(api.bookReceiving).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('blocks plans for confirmed, waiting-change, completed and unauthorized customers', async () => {
|
||||
const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }]
|
||||
c.executionScope.members.find(row => row.member.customerId === 'customer-d')!.completed = true
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成').every(row => row.attributes('disabled') !== undefined)).toBe(true)
|
||||
wrapper.unmount()
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
|
||||
const readonly = render(); await enterBPlan(readonly); await readonly.setProps({ canWrite: false })
|
||||
await readonly.get('[aria-label="确认当前范围"]').setValue(true); await readonly.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
it('starts D plan empty without copying confirmed A or another customer plan and clears an existing B date explicitly', async () => {
|
||||
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').filter(row => row.text() === '保存目标 / 未约成')[2]!.trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
||||
await button(wrapper, '返回范围概览').trigger('click'); await flushPromises(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue('')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ member: expect.objectContaining({ customerId: 'customer-b' }), details: expect.objectContaining({ plannedDate: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves cross-day planned windows and explicit DST offsets in the customer plan without confirmation fields', async () => {
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-10-31')
|
||||
await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-10-31T23:30:00')
|
||||
await wrapper.get('[aria-label="客户计划结束"]').setValue('2026-11-01T01:30:00')
|
||||
await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-07:00')
|
||||
await wrapper.get('[aria-label="客户计划结束时差"]').setValue('-08:00')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: '2026-10-31', plannedAt: expect.stringContaining('2026-10-31T23:30'), plannedEndAt: expect.stringContaining('2026-11-01T01:30'), startOffset: '-07:00', endOffset: '-08:00', appointmentAt: null, appointmentEndAt: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('blocks dated plans without authoritative timezone but permits undated failure notes without a browser timezone', async () => {
|
||||
const c = mixedPlans(); c.plans[0]!.timezone = null
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('目的仓时区待核实,请先补齐')
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue(''); await wrapper.get('[aria-label="客户计划状态"]').setValue('FAILED')
|
||||
await wrapper.get('[aria-label="客户计划备注"]').setValue('待核实仓库地点')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ plannedDate: null, appointmentStatus: 'FAILED', startOffset: null, endOffset: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves a DST-rejected plan for correction and never copies it into a formal booking', async () => {
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
|
||||
vi.mocked(api.saveReceivingPlan).mockRejectedValueOnce(new ApiResponseError('当地时刻存在歧义,请明确时差', 'A0400', 400))
|
||||
const wrapper = render(); await enterBPlan(wrapper)
|
||||
await wrapper.get('[aria-label="客户计划日期"]').setValue('2026-11-01'); await wrapper.get('[aria-label="客户计划开始"]').setValue('2026-11-01T01:30:00')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当地时刻存在歧义')
|
||||
expect((wrapper.get('[aria-label="客户计划开始"]').element as HTMLInputElement).value).toContain('2026-11-01T01:30')
|
||||
expect(button(wrapper, '保存本客户计划').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[aria-label="客户计划开始时差"]').setValue('-08:00'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenCalledTimes(2); expect(api.bookReceiving).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('inherits unconfirmed legacy header plans only without active independent coverage and strips stale confirmation fields', async () => {
|
||||
for (const status of ['PENDING', 'FAILED'] as const) {
|
||||
const c = context(); Object.assign(c.batch, { timezone: 'America/Los_Angeles', plannedDate: '2026-10-27', plannedAt: '2026-10-27T10:00:00', appointmentStatus: status, appointmentRemark: '原联系原因', confirmationNo: 'STALE', appointmentAt: '2026-10-20T09:00:00' })
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('2026-10-27')
|
||||
expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('原联系原因')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.saveReceivingPlan).toHaveBeenLastCalledWith(expect.objectContaining({ details: expect.objectContaining({ appointmentStatus: status, plannedDate: '2026-10-27', confirmationNo: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null }) }))
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
|
||||
for (const ownPlan of [false, true]) {
|
||||
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
|
||||
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
|
||||
expect((wrapper.get('[aria-label="客户计划备注"]').element as HTMLTextAreaElement).value).toBe('')
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
it('selects only A for independent rebooking from a cancelled group sharing one stable batch with B and D', async () => {
|
||||
const c = mixedPlans(); c.agreements = [{ ...agreement('CANCELLED'), members: [...members(), { ...members()[1]!, customerId: 'customer-d', customerName: '丁' }] }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: c.agreements[0]!.members, excludedMembers: [], transport: c.transport, executionScope: c.executionScope })
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false); await wrapper.get('[aria-label="选择预约客户 3"]').setValue(false)
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'A-ONLY', appointmentAt: '2026-10-24T10:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅甲取得确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(api.bookReceiving).mock.calls[0]![0]
|
||||
expect(request.bookings).toHaveLength(1); expect(request.bookings[0]!.members).toEqual([{ batchKey: c.batch.batchKey!, batchVersion: 3, customerId: 'customer-a' }])
|
||||
expect(request.executionVersion).toBe(c.executionScope.version); expect(request.cancelledAgreementKey).toBe('GROUP-1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps independent booking fields matched to the selected customer and blocks an empty selection', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('label').find(label => label.text().includes('所选客户分别登记预约'))!.get('input').setValue(true)
|
||||
wrapper.findAllComponents({ name: 'FbaAppointmentFields' }).forEach((fields, index) => Object.assign(fields.props('model'), { confirmationNo: `FOR-${index}`, appointmentAt: '2026-10-24T10:00:00' }))
|
||||
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false)
|
||||
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo).toBe('FOR-1')
|
||||
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(false)
|
||||
expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled()
|
||||
await wrapper.get('[aria-label="选择预约客户 2"]').setValue(true); await flushPromises(); await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙确认')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(vi.mocked(api.bookReceiving).mock.calls[0]![0].bookings[0]).toEqual(expect.objectContaining({ members: [expect.objectContaining({ customerId: 'customer-b' })], details: expect.objectContaining({ confirmationNo: 'FOR-1' }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reads an exact cross-batch subset execution token without changing the visible batch or cancelled-group versions', async () => {
|
||||
const c = context(); const a = members()[0]!, b = { ...members()[1]!, batchKey: 'FBA_APPOINTMENT:2', batchVersion: 7, appointmentNo: 'AP-B' }
|
||||
c.agreements = [{ ...agreement('CANCELLED'), members: [a, b] }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
vi.mocked(api.getReceivingGroup).mockResolvedValue({ agreement: c.agreements[0]!, eligibleMembers: [a, b], excludedMembers: [], transport: c.transport, executionScope: { ...c.executionScope, version: 'whole-A-B' } })
|
||||
vi.mocked(api.getReceivingExecutionScope).mockImplementation(async selected => ({ version: selected.length === 1 ? 'only-B-current' : 'whole-A-B-current', members: selected.map(member => ({ member, completed: false, canRebook: true, reason: null })) }))
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '重新预约这组货物').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises()
|
||||
expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: b.batchKey, customerId: b.customerId }])
|
||||
Object.assign(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model'), { confirmationNo: 'B-ONLY', appointmentAt: '2026-10-24T10:00:00' })
|
||||
await wrapper.get('[aria-label="预约覆盖依据"]').setValue('仅乙当前批确认'); await wrapper.get('[aria-label="确认当前范围"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.bookReceiving).toHaveBeenCalledWith(expect.objectContaining({ executionVersion: 'only-B-current', cancelledAgreementVersion: 4, bookings: [expect.objectContaining({ members: [{ batchKey: b.batchKey, batchVersion: 7, customerId: b.customerId }] })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('blocks submission after a selected execution-scope read failure and retries only that read', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
vi.mocked(api.getReceivingExecutionScope).mockRejectedValueOnce(new Error('执行资格暂不可用'))
|
||||
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="确认当前范围"]').attributes('disabled')).toBeDefined(); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined()
|
||||
await button(wrapper, '重试读取').trigger('click'); await flushPromises()
|
||||
expect(api.getReceivingExecutionScope).toHaveBeenLastCalledWith([{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }])
|
||||
expect((wrapper.get('[aria-label="确认当前范围"]').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('shows authoritative subset blockers and ignores a late response for a previous selection', async () => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '登记当前未覆盖货物的正式预约').trigger('click'); await flushPromises()
|
||||
let resolveOld!: (scope: api.ReceivingExecutionScope) => void
|
||||
vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(false)
|
||||
vi.mocked(api.getReceivingExecutionScope).mockImplementationOnce(async selected => ({ version: 'new-blocked', members: selected.map(member => ({ member, completed: true, canRebook: false, reason: '本客户已执行,不能重约' })) }))
|
||||
await wrapper.get('[aria-label="选择预约客户 1"]').setValue(true); await flushPromises()
|
||||
resolveOld({ version: 'stale-allowed', members: [{ member: { batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-b' }, completed: false, canRebook: true, reason: null }] }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('本客户已执行,不能重约')
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.bookReceiving).not.toHaveBeenCalled(); expect(button(wrapper, '保存本次正式预约').attributes('disabled')).toBeDefined(); wrapper.unmount()
|
||||
})
|
||||
|
||||
function verifiedCargo(): CargoVerificationContext {
|
||||
return { source: { sourceKey: 'BOOKING:2', customerId: 'customer-b', orderId: null, inboundCode: 'IN-B', bookingNo: null, containerNo: null, originWarehouseId: 'origin-1', originWarehouseName: '本仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 40, sourceVersion: 'source-latest', canFollowup: true, blockReasons: [] }, verification: { version: 2, sourceVersion: 'source-latest', destinationKey: 'FBA:ONT8', sourcePackages: 40, packageCount: 40, shipmentReferences: ['FBA-NEW-B'], remark: null, operatorName: '核实人员', verifiedAt: '2026-10-16T10:00:00Z' }, allocatedPackages: 30, unknownAllocationCount: 0, effectivePackages: 40, availablePackages: 10, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||
}
|
||||
async function enterRefresh(wrapper: ReturnType<typeof render>) {
|
||||
await flushPromises(); await wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')[1]!.trigger('click'); await flushPromises()
|
||||
}
|
||||
it('explicitly adopts only B latest source and verification snapshots beside independently confirmed A without changing quantities or saving verification', async () => {
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo())
|
||||
const wrapper = render(); await enterRefresh(wrapper)
|
||||
expect(getCargoVerification).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'BOOKING:2', customerId: 'customer-b', destinationKey: 'FBA:ONT8' })
|
||||
expect(wrapper.text()).toContain('本批 30 箱'); expect(wrapper.text()).toContain('最新核实 40 箱'); expect(wrapper.text()).toContain('FBA-NEW-B')
|
||||
expect(api.refreshReceivingCargo).not.toHaveBeenCalled()
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.refreshReceivingCargo).toHaveBeenCalledWith(expect.objectContaining({ member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-b' }, executionVersion: 'execution-v1', sources: [{ sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', expectedSourceVersion: 'source-latest', expectedVerificationVersion: 2 }] }))
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled(); expect(api.proposeReceivingChange).not.toHaveBeenCalled(); expect(api.bookReceiving).not.toHaveBeenCalled()
|
||||
await button(wrapper, '继续本客户目标 / 未约成').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[aria-label="客户计划日期"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('fails closed for unknown, changed, overallocated, mismatched or unavailable latest cargo', async () => {
|
||||
const cases: Array<Partial<CargoVerificationContext>> = [
|
||||
{ sourceChanged: true }, { effectivePackages: null, availablePackages: null }, { unknownAllocationCount: 1 }, { allocatedPackages: 41 },
|
||||
{ source: { ...verifiedCargo().source, customerId: 'customer-other' } }, { source: { ...verifiedCargo().source, destinationKey: 'FBA:OTHER' } },
|
||||
{ source: { ...verifiedCargo().source, sourceVersion: null } }, { verification: { ...verifiedCargo().verification!, sourceVersion: 'stale' } }, { blockReasons: ['来源暂不可用'] },
|
||||
]
|
||||
for (const change of cases) {
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans()); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), ...change })
|
||||
const wrapper = render(); await enterRefresh(wrapper)
|
||||
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
}
|
||||
vi.mocked(getCargoVerification).mockRejectedValueOnce(new Error('核实来源读取失败'))
|
||||
const wrapper = render(); await enterRefresh(wrapper)
|
||||
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('最新货物核实资料读取失败'); wrapper.unmount()
|
||||
})
|
||||
it('refreshes rejected adoption versions for explicit reconfirmation and retries unknown adoption with the identical key', async () => {
|
||||
const c = mixedPlans(); vi.mocked(api.getReceivingContext).mockResolvedValue(c); vi.mocked(getCargoVerification).mockResolvedValue(verifiedCargo())
|
||||
vi.mocked(api.refreshReceivingCargo).mockRejectedValueOnce(new ApiResponseError('资料版本变化', 'A0409', 409)).mockRejectedValueOnce(new Error('timeout'))
|
||||
const wrapper = render(); await enterRefresh(wrapper)
|
||||
const fresh = structuredClone(c); fresh.batch.version = 4; fresh.executionScope.version = 'exec-new'
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(fresh); vi.mocked(getCargoVerification).mockResolvedValue({ ...verifiedCargo(), verification: { ...verifiedCargo().verification!, version: 3 } })
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(button(wrapper, '确认采用本客户最新资料').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[aria-label="确认当前范围"]').setValue(true); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const second = vi.mocked(api.refreshReceivingCargo).mock.calls[1]![0]
|
||||
expect(second.member.batchVersion).toBe(4); expect(second.sources[0]!.expectedVerificationVersion).toBe(3); expect(second.executionVersion).toBe('exec-new'); expect(await guard()).toBe(false)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(api.refreshReceivingCargo).toHaveBeenNthCalledWith(3, second); expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('requires original source read permissions and blocks adoption for confirmed or waiting-change customers', async () => {
|
||||
const c = mixedPlans(); c.pendingChanges = [{ ...pendingChange(), intent: { ...pendingChange().intent, customerId: 'customer-b' } }]
|
||||
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const buttons = wrapper.findAll('button').filter(row => row.text() === '核对并采用本客户最新货物资料')
|
||||
expect(buttons[0]!.attributes('disabled')).toBeDefined(); expect(buttons[1]!.attributes('disabled')).toBeDefined(); wrapper.unmount()
|
||||
access.denied = ['platform:overseas-warehouse:last-mile-order:list']; vi.mocked(api.getReceivingContext).mockResolvedValue(mixedPlans())
|
||||
const denied = render(); await enterRefresh(denied)
|
||||
expect(getCargoVerification).not.toHaveBeenCalled(); expect(api.refreshReceivingCargo).not.toHaveBeenCalled(); denied.unmount()
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createAppointmentForm, appointmentPayload, appointmentFormError, FbaAppointmentFields } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
import ReceivingScope from './ReceivingScope.vue'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import { refreshReceivingCargo, type ReceivingCargoRefresh, getReceivingExecutionScope, type ReceivingExecutionScope, saveReceivingPlan, type ReceivingPlan, getReceivingContext, getReceivingGroup, proposeReceivingChange, resolveReceivingChange, bookReceiving, changeReceivingGroup, type ReceivingContext, type ReceivingGroup, type ReceivingMember, type ReceivingPending, type ReceivingResult, type ReceivingPropose, type ReceivingResolve, type ReceivingBook, type ReceivingGroupChange } from './receivingApi'
|
||||
|
||||
const props = defineProps<{ batchKey: string; canWrite: boolean; initialCustomerId?: string; owners?: Array<{ id: string; name: string }> }>()
|
||||
const emit = defineEmits<{ updated: []; appointment: [number: string]; batch: [key: string]; external: [path: string] }>()
|
||||
const auth = useAuthStore()
|
||||
const owners = computed(() => [...new Map([...(props.owners || []), ...(auth.user ? [{ id: auth.user.id, name: auth.user.name }] : [])].filter(person => person.id).map(person => [person.id, person])).values()])
|
||||
const access = useAccessPreviewStore(), context = ref<ReceivingContext | null>(null), group = ref<ReceivingGroup | null>(null)
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), result = ref<ReceivingResult | null>(null)
|
||||
const confirming = ref(false)
|
||||
const mode = ref<'refresh' | 'plan' | 'overview' | 'customer' | 'resolve' | 'book' | 'reschedule' | 'cancel'>('overview')
|
||||
const customer = ref(''), change = ref<ReceivingPending | null>(null), groupKey = ref<string | null>(null)
|
||||
const adjustment = reactive({ retained: {} as Record<string, number | string>, laterDate: '', ownerId: '', followup: '', reason: '' })
|
||||
const evidence = ref(''), separate = ref(false), confirmedScope = ref(false)
|
||||
const bookingForms = ref<Array<ReturnType<typeof createAppointmentForm>>>([]), bookingEvidence = ref<string[]>([])
|
||||
const bookingMemberKeys = ref<string[]>([]), selectedMemberKeys = ref<string[]>([])
|
||||
const selectedExecution = ref<ReceivingExecutionScope | null>(null), scopeLoading = ref(false), scopeError = ref<ApiErrorDetails | null>(null)
|
||||
const cargoContexts = ref<CargoVerificationContext[]>([]), cargoLoading = ref(false), cargoError = ref<ApiErrorDetails | null>(null)
|
||||
let cargoGeneration = 0
|
||||
let scopeGeneration = 0
|
||||
let initializedGroup: string | null = null
|
||||
const planForm = reactive(createAppointmentForm())
|
||||
const groupForm = reactive(createAppointmentForm())
|
||||
const state = () => JSON.stringify({ selectedMemberKeys: selectedMemberKeys.value, planForm, adjustment, evidence: evidence.value, separate: separate.value, bookingForms: bookingForms.value, bookingEvidence: bookingEvidence.value, groupForm })
|
||||
const baseline = ref(state())
|
||||
type Command = { kind: 'refresh'; request: ReceivingCargoRefresh } | { kind: 'plan'; request: ReceivingPlan } | { kind: 'propose'; request: ReceivingPropose } | { kind: 'resolve'; key: string; request: ReceivingResolve } | { kind: 'book'; request: ReceivingBook } | { kind: 'group'; key: string; request: ReceivingGroupChange }
|
||||
const pending = ref<Command | null>(null)
|
||||
const canLeave = useWorkDraft(computed(() => state() !== baseline.value), computed(() => saving.value || confirming.value || !!pending.value))
|
||||
const readable = computed(() => access.can('platform:appointment:list'))
|
||||
const edit = computed(() => props.canWrite && access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
const remove = computed(() => props.canWrite && access.can('platform:appointment:delete'))
|
||||
const formWritable = computed(() => mode.value === 'cancel' ? remove.value : ['customer', 'resolve'].includes(mode.value) ? edit.value && remove.value : edit.value)
|
||||
const locked = computed(() => loading.value || saving.value || confirming.value || !!error.value || !!pending.value)
|
||||
const sourceKey = (source: { sourceKey: string; destinationKey: string }) => JSON.stringify([source.sourceKey, source.destinationKey])
|
||||
const customerItems = computed(() => context.value?.batch.items.filter(item => item.customerId === customer.value) || [])
|
||||
const customers = computed(() => [...new Set(context.value?.batch.items.map(item => item.customerId) || [])])
|
||||
function customerName(id: string) { return context.value?.agreements.flatMap(row => row.members).find(member => member.customerId === id)?.customerName || context.value?.pendingChanges.flatMap(row => row.originalMembers).find(member => member.customerId === id)?.customerName || null }
|
||||
const eligibleCustomers = computed(() => customers.value.filter(id => context.value?.executionScope.members.some(row => row.member.batchKey === context.value?.batch.batchKey && row.member.customerId === id && row.canRebook && !row.completed)))
|
||||
const uncoveredCustomers = computed(() => eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === props.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.batchKey === props.batchKey && c.intent.customerId === id)))
|
||||
const refreshReadable = computed(() => readable.value && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
const refreshEligible = (id: string) => refreshReadable.value && !!context.value?.allowedActions.includes('REFRESH_CARGO') && uncoveredCustomers.value.includes(id)
|
||||
const planEligible = (id: string) => !!context.value?.allowedActions.includes('PLAN_RECEIVING') && uncoveredCustomers.value.includes(id)
|
||||
const customerPlan = (id: string) => context.value?.plans.find(plan => plan.batchKey === props.batchKey && plan.customerId === id)
|
||||
const planTimezone = computed(() => { const saved = customerPlan(customer.value); return saved ? saved.timezone : context.value?.batch.timezone })
|
||||
const cargoIncomplete = computed(() => !!context.value?.batch.items.filter(item => eligibleCustomers.value.includes(item.customerId)).some(item => item.packageCount == null || !item.destinationKey || !item.sourceVersion))
|
||||
const bookMembers = computed<ReceivingMember[]>(() => {
|
||||
if (group.value) return group.value.eligibleMembers
|
||||
const batch = context.value?.batch
|
||||
if (!batch?.batchKey || cargoIncomplete.value) return []
|
||||
return eligibleCustomers.value.filter(id => !context.value!.agreements.some(a => a.status === 'ACTIVE' && a.members.some(m => m.batchKey === batch.batchKey && m.customerId === id)) && !context.value!.pendingChanges.some(c => c.status === 'WAITING' && c.intent.customerId === id)).map(id => ({ batchKey: batch.batchKey!, appointmentNo: batch.appointmentNo, batchVersion: batch.version, customerId: id, customerName: customerName(id), sources: batch.items.filter(item => item.customerId === id).map(item => ({ sourceKey: item.sourceKey, inboundCode: item.inboundCode || null, destinationKey: item.destinationKey!, packageCount: item.packageCount!, sourceVersion: item.sourceVersion!, verificationVersion: item.cargoVerificationVersion ?? null })) }))
|
||||
})
|
||||
const cargoProblems = computed(() => {
|
||||
const problems: string[] = []
|
||||
if (!customerItems.value.length || customerItems.value.length > 100 || cargoContexts.value.length !== customerItems.value.length) return ['本客户来源范围未完整读取']
|
||||
if (new Set(customerItems.value.map(item => JSON.stringify([item.sourceKey, item.destinationKey]))).size !== customerItems.value.length) problems.push('本客户存在重复来源,请核对原批次')
|
||||
customerItems.value.forEach((item, index) => {
|
||||
const row = cargoContexts.value[index]!, source = row.source, record = row.verification
|
||||
if (source.sourceKey !== item.sourceKey || source.customerId !== customer.value || source.destinationKey !== item.destinationKey || source.originWarehouseId !== context.value?.batch.originWarehouseId || record && record.destinationKey !== item.destinationKey) problems.push('货物身份、起运仓或目的地与本批不一致')
|
||||
if (row.sourceChanged || record && record.sourceVersion !== source.sourceVersion) problems.push('原货源已变化,请先重新核实资料')
|
||||
if (!source.sourceVersion || record && (!Number.isSafeInteger(record.version) || record.version < 1)) problems.push('最新货物资料版本不完整')
|
||||
if (item.packageCount == null || item.packageCount <= 0 || row.effectivePackages == null || !Number.isSafeInteger(row.effectivePackages) || row.effectivePackages <= 0 || row.availablePackages == null || row.availablePackages < 0 || row.unknownAllocationCount !== 0 || !Number.isSafeInteger(row.allocatedPackages) || row.allocatedPackages < 0) problems.push('本批箱数、最新核实量或分配量待核实')
|
||||
if (row.effectivePackages != null && (row.allocatedPackages > row.effectivePackages || (item.packageCount ?? 0) > row.effectivePackages)) problems.push('已分配量或本批箱数超过最新核实量')
|
||||
problems.push(...row.blockReasons, ...source.blockReasons)
|
||||
})
|
||||
return [...new Set(problems)]
|
||||
})
|
||||
const memberKey = (member: Pick<ReceivingMember, 'batchKey' | 'customerId'>) => JSON.stringify([member.batchKey, member.customerId])
|
||||
const selectedBookMembers = computed(() => bookMembers.value.filter(member => selectedMemberKeys.value.includes(memberKey(member))))
|
||||
const bookingRows = computed(() => !selectedBookMembers.value.length ? [] : separate.value ? selectedBookMembers.value.map(member => ({ member, index: bookMembers.value.findIndex(row => memberKey(row) === memberKey(member)) })) : [{ member: null, index: 0 }])
|
||||
const selectedExecutionAllowed = computed(() => !!selectedExecution.value?.version && selectedBookMembers.value.every(member => selectedExecution.value!.members.some(row => memberKey(row.member) === memberKey(member) && row.canRebook && !row.completed)))
|
||||
const bookingScopeChanged = computed(() => JSON.stringify(bookingMemberKeys.value) !== JSON.stringify(bookMembers.value.map(memberKey)))
|
||||
const currentTrips = computed(() => [...new Set((group.value?.transport || context.value?.transport)?.vehicles.map(vehicle => vehicle.tripNo).filter((number): number is string => !!number) || [])])
|
||||
const laterBatch = computed(() => result.value?.batches.find(batch => result.value?.steps.some(step => step.step === 'ALLOCATE_CARGO' && step.status === 'SUCCESS' && step.targetKey === batch.batchKey)))
|
||||
const canSubmit = computed(() => {
|
||||
if (loading.value || error.value || saving.value || confirming.value || result.value || !context.value) return false
|
||||
if (pending.value) return pending.value.kind === 'group' && pending.value.request.action === 'CANCEL' ? remove.value : pending.value.kind === 'propose' || pending.value.kind === 'resolve' ? edit.value && remove.value : edit.value
|
||||
if (!confirmedScope.value) return false
|
||||
if (mode.value === 'refresh') return edit.value && refreshEligible(customer.value) && !cargoLoading.value && !cargoError.value && !cargoProblems.value.length
|
||||
if (mode.value === 'plan') return edit.value && planEligible(customer.value)
|
||||
if (mode.value === 'customer') return edit.value && remove.value && context.value.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')
|
||||
if (mode.value === 'resolve') return edit.value && remove.value && change.value?.status === 'WAITING'
|
||||
if (mode.value === 'cancel') return remove.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
|
||||
if (mode.value === 'reschedule') return edit.value && group.value?.agreement.status === 'ACTIVE' && !group.value.transport.blockReasons.length
|
||||
return mode.value === 'book' && edit.value && !!selectedBookMembers.value.length && !bookingScopeChanged.value && !scopeLoading.value && !scopeError.value && selectedExecutionAllowed.value && (group.value ? group.value.agreement.status === 'CANCELLED' : context.value.allowedActions.includes('BOOK_RECEIVING'))
|
||||
})
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const next = await getReceivingContext(props.batchKey)
|
||||
const nextGroup = groupKey.value ? await getReceivingGroup(groupKey.value) : null
|
||||
context.value = next; group.value = nextGroup
|
||||
if (nextGroup && initializedGroup !== nextGroup.agreement.agreementKey) {
|
||||
Object.assign(groupForm, createAppointmentForm(), Object.fromEntries(Object.entries(nextGroup.agreement.details).map(([key, value]) => [key, value ?? ''])))
|
||||
if (mode.value === 'book') initializeBookings()
|
||||
initializedGroup = nextGroup.agreement.agreementKey; baseline.value = state()
|
||||
}
|
||||
if (change.value) change.value = next.pendingChanges.find(row => row.changeKey === change.value?.changeKey) || null
|
||||
if (mode.value === 'book' && !result.value) await refreshSelectedScope()
|
||||
if (mode.value === 'refresh' && !result.value) await loadCargo()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '预约覆盖范围读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function resetForm() { ++cargoGeneration; cargoLoading.value = false; cargoContexts.value = []; cargoError.value = null; ++scopeGeneration; selectedExecution.value = null; scopeLoading.value = false; scopeError.value = null; evidence.value = ''; confirmedScope.value = false; result.value = null; saveError.value = null; separate.value = false }
|
||||
async function overview() { if (!await canLeave()) return; mode.value = 'overview'; groupKey.value = null; group.value = null; change.value = null; resetForm(); baseline.value = state(); await load() }
|
||||
async function adjust(id: string) {
|
||||
if (locked.value || !await canLeave()) return
|
||||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'customer'
|
||||
adjustment.retained = Object.fromEntries(customerItems.value.filter(item => item.destinationKey).map(item => [sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! }), item.packageCount == null ? '' : item.packageCount]))
|
||||
adjustment.laterDate = ''; adjustment.ownerId = owners.value[0]?.id || ''; adjustment.followup = ''; adjustment.reason = ''; baseline.value = state()
|
||||
}
|
||||
async function openCargoRefresh(id: string) {
|
||||
if (locked.value || !refreshEligible(id) || !await canLeave()) return
|
||||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'refresh'; baseline.value = state(); await loadCargo()
|
||||
}
|
||||
async function loadCargo() {
|
||||
const generation = ++cargoGeneration; confirmedScope.value = false; cargoContexts.value = []; cargoError.value = null; cargoLoading.value = false
|
||||
if (!refreshReadable.value || pending.value) return
|
||||
if (!customerItems.value.length || customerItems.value.length > 100 || customerItems.value.some(item => !item.destinationKey)) { cargoError.value = { message: '本客户来源或目的地不完整,不能采用资料' }; return }
|
||||
cargoLoading.value = true
|
||||
try {
|
||||
const rows = await Promise.all(customerItems.value.map(item => getCargoVerification({ sourceKey: item.sourceKey, customerId: customer.value, destinationKey: item.destinationKey! })))
|
||||
if (generation === cargoGeneration) cargoContexts.value = rows
|
||||
} catch (cause) { if (generation === cargoGeneration) cargoError.value = getApiErrorDetails(cause, '最新货物核实资料读取失败') }
|
||||
finally { if (generation === cargoGeneration) cargoLoading.value = false }
|
||||
}
|
||||
async function openPlan(id: string) {
|
||||
if (locked.value || !planEligible(id) || !await canLeave()) return
|
||||
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
|
||||
const saved = customerPlan(id)
|
||||
const header = context.value!.batch
|
||||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
|
||||
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()
|
||||
}
|
||||
async function resume(pendingChange: ReceivingPending) { if (locked.value || !await canLeave()) return; resetForm(); change.value = pendingChange; mode.value = 'resolve'; baseline.value = state() }
|
||||
function initializeBookings() { bookingMemberKeys.value = bookMembers.value.map(memberKey); selectedMemberKeys.value = [...bookingMemberKeys.value]; bookingForms.value = bookMembers.value.map(() => ({ ...createAppointmentForm(), appointmentStatus: 'SUCCESS' })); bookingEvidence.value = bookMembers.value.map(() => ''); confirmedScope.value = false; baseline.value = state() }
|
||||
async function resetBookingScope() { if (await canLeave()) { initializeBookings(); await refreshSelectedScope() } }
|
||||
async function openGroup(key: string, next: 'book' | 'reschedule' | 'cancel') {
|
||||
if (locked.value || !await canLeave()) return
|
||||
resetForm(); groupKey.value = key; group.value = null; initializedGroup = null; mode.value = next; await load()
|
||||
baseline.value = state()
|
||||
}
|
||||
async function newBooking() { if (locked.value || !await canLeave()) return; resetForm(); groupKey.value = null; group.value = null; mode.value = 'book'; initializeBookings(); await refreshSelectedScope() }
|
||||
async function refreshSelectedScope() {
|
||||
const generation = ++scopeGeneration
|
||||
confirmedScope.value = false; selectedExecution.value = null; scopeError.value = null; scopeLoading.value = false
|
||||
if (mode.value !== 'book' || !readable.value || !selectedBookMembers.value.length || bookingScopeChanged.value) return
|
||||
const members = selectedBookMembers.value.map(({ batchKey, customerId }) => ({ batchKey, customerId }))
|
||||
scopeLoading.value = true
|
||||
try {
|
||||
const scope = await getReceivingExecutionScope(members)
|
||||
if (generation === scopeGeneration) selectedExecution.value = scope
|
||||
} catch (cause) { if (generation === scopeGeneration) scopeError.value = getApiErrorDetails(cause, '所选客户执行资格读取失败') }
|
||||
finally { if (generation === scopeGeneration) scopeLoading.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { ++scopeGeneration; ++cargoGeneration })
|
||||
function validation(message: string): never { throw new Error(message) }
|
||||
function confirmedDetails(form: ReturnType<typeof createAppointmentForm>) { const invalid = appointmentFormError(form); if (invalid) validation(invalid); if (form.appointmentStatus !== 'SUCCESS') validation('此处登记仓库正式确认;尚未确认请使用本客户的“保存目标 / 未约成”入口'); return appointmentPayload(form) }
|
||||
function buildCommand(decision: 'CONFIRM' | 'WITHDRAW'): Command {
|
||||
const current = context.value!, idempotencyKey = crypto.randomUUID()
|
||||
if (mode.value === 'refresh') {
|
||||
return { kind: 'refresh', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, executionVersion: current.executionScope.version, sources: cargoContexts.value.map(row => ({ sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey, expectedSourceVersion: row.source.sourceVersion!, expectedVerificationVersion: row.verification?.version ?? null })) } }
|
||||
}
|
||||
if (mode.value === 'plan') {
|
||||
if (!['PENDING', 'FAILED'].includes(planForm.appointmentStatus)) validation('客户计划只能保存等待确认或未约成')
|
||||
if (planForm.confirmationNo || planForm.appointmentAt || planForm.appointmentEndAt) validation('客户计划不能写入正式确认')
|
||||
if ((planForm.plannedDate || planForm.plannedAt || planForm.plannedEndAt) && !planTimezone.value) validation('目的仓时区待核实,请先补齐;日期未定时可保存联系结果')
|
||||
if (planForm.plannedEndAt && !planForm.plannedAt) validation('填写结束时段前请填写计划开始时段')
|
||||
if (planForm.startOffset && !planForm.plannedAt || planForm.endOffset && !planForm.plannedEndAt) validation('仅填写对应计划时段时才可填写时差;仅日期或未定日期请清空时差')
|
||||
for (const offset of [planForm.startOffset, planForm.endOffset]) if (offset.trim() && !/^[+-](?:0\d|1[0-7]):[0-5]\d$|^[+-]18:00$/.test(offset.trim())) validation('计划时差请按 +08:00 或 -07:00 格式填写')
|
||||
const invalid = appointmentFormError(planForm); if (invalid) validation(invalid)
|
||||
return { kind: 'plan', request: { idempotencyKey, member: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value }, details: appointmentPayload(planForm), executionVersion: current.executionScope.version } }
|
||||
}
|
||||
if (mode.value === 'customer') {
|
||||
if (!owners.value.some(person => person.id === adjustment.ownerId) || !adjustment.reason.trim()) validation('请选择有效负责人并填写调整原因')
|
||||
const quantities = customerItems.value.map(item => {
|
||||
if (!item.destinationKey || !item.sourceVersion || item.packageCount == null) validation('本票数量或来源版本待核实,请先核实货物')
|
||||
const raw = adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey! })]
|
||||
if (raw === '' || raw == null) validation('请填写每票本次先送箱数,全部移出可填 0')
|
||||
const retainedPackages = Number(raw)
|
||||
if (!Number.isSafeInteger(retainedPackages) || retainedPackages < 0 || retainedPackages > item.packageCount!) validation('先送箱数须为 0 至本票当前箱数之间的整数')
|
||||
return { sourceKey: item.sourceKey, destinationKey: item.destinationKey!, retainedPackages, expectedSourceVersion: item.sourceVersion!, expectedVerificationVersion: item.cargoVerificationVersion ?? null }
|
||||
})
|
||||
if (quantities.every((q, index) => q.retainedPackages === customerItems.value[index]!.packageCount)) validation('本次先送量未改变,无需提交调整')
|
||||
return { kind: 'propose', request: { idempotencyKey, intent: { batchKey: props.batchKey, batchVersion: current.batch.version, customerId: customer.value, quantities, laterDate: adjustment.laterDate || null, ownerId: adjustment.ownerId, nextFollowupDate: adjustment.followup || null, reason: adjustment.reason.trim(), transportVersion: current.transport.version } } }
|
||||
}
|
||||
if (mode.value === 'resolve') {
|
||||
if (!change.value) validation('待确认记录已变化,请刷新后核对')
|
||||
if (decision === 'CONFIRM' && !evidence.value.trim()) validation('请填写仓库同意本次保留量和移出量的回复依据')
|
||||
return { kind: 'resolve', key: change.value!.changeKey, request: { idempotencyKey, version: change.value!.version, decision, warehouseEvidence: evidence.value.trim() || null, transportVersion: current.transport.version } }
|
||||
}
|
||||
if (mode.value === 'book') {
|
||||
const selected = selectedBookMembers.value
|
||||
const groups = separate.value ? selected.map(member => [member]) : [selected]
|
||||
return { kind: 'book', request: { idempotencyKey, cancelledAgreementKey: group.value?.agreement.agreementKey || null, cancelledAgreementVersion: group.value?.agreement.version ?? null, executionVersion: selectedExecution.value!.version, bookings: groups.map((members, index) => {
|
||||
const formIndex = bookingRows.value[index]!.index
|
||||
const note = bookingEvidence.value[formIndex]?.trim(); if (!note) validation('请逐份填写仓库确认覆盖客户和货量的依据')
|
||||
return { members: members.map(({ batchKey, batchVersion, customerId }) => ({ batchKey, batchVersion, customerId })), details: confirmedDetails(bookingForms.value[formIndex]!), warehouseEvidence: note! }
|
||||
}) } }
|
||||
}
|
||||
if (!group.value || !evidence.value.trim()) validation('请核对共同预约范围并填写仓库回复依据')
|
||||
return { kind: 'group', key: group.value!.agreement.agreementKey, request: { idempotencyKey, version: group.value!.agreement.version, action: mode.value === 'cancel' ? 'CANCEL' : 'RESCHEDULE', details: mode.value === 'cancel' ? null : confirmedDetails(groupForm), warehouseEvidence: evidence.value.trim(), transportVersion: group.value!.transport.version } }
|
||||
}
|
||||
async function save(decision: 'CONFIRM' | 'WITHDRAW' = 'CONFIRM') {
|
||||
if (!canSubmit.value) return
|
||||
if (!pending.value) {
|
||||
try {
|
||||
const command = buildCommand(decision)
|
||||
if (command.kind === 'group' && command.request.action === 'CANCEL') {
|
||||
confirming.value = true
|
||||
try { await ElMessageBox.confirm('确认取消当前展示的整份预约?货物需求与批次保留,车辆需重新核对。', '取消整份预约', { customClass: 'admin-density-confirm', confirmButtonText: '确认取消', cancelButtonText: '返回核对' }) } catch { return } finally { confirming.value = false }
|
||||
}
|
||||
pending.value = command
|
||||
} catch (cause) { saveError.value = { message: cause instanceof Error ? cause.message : '请核对本次范围' }; return }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
const command = pending.value
|
||||
result.value = command.kind === 'refresh' ? await refreshReceivingCargo(command.request) : command.kind === 'plan' ? await saveReceivingPlan(command.request) : command.kind === 'propose' ? await proposeReceivingChange(command.request) : command.kind === 'resolve' ? await resolveReceivingChange(command.key, command.request) : command.kind === 'book' ? await bookReceiving(command.request) : await changeReceivingGroup(command.key, command.request)
|
||||
pending.value = null; baseline.value = state(); confirmedScope.value = false; emit('updated'); await load()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; confirmedScope.value = false; await load() } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function openBatch(key: string) { if (await canLeave()) emit('batch', key) }
|
||||
onMounted(async () => { await load(); if (props.initialCustomerId && customers.value.includes(props.initialCustomerId)) await adjust(props.initialCustomerId) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="客户调整与共同预约">
|
||||
<h3>{{ context?.batch.appointmentNo || '预约批次' }} · 客户调整与共同预约</h3>
|
||||
<p v-if="!readable">当前账号没有预约列表读取权限。</p>
|
||||
<LoadErrorState v-if="error" title="预约覆盖范围读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对货物、预约覆盖与车辆…</p>
|
||||
<template v-if="context">
|
||||
<el-button v-if="mode !== 'overview' || result" :disabled="locked" @click="overview">返回范围概览</el-button>
|
||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<section v-if="result" aria-label="本次处理结果">
|
||||
<h4>{{ result.status === 'WAITING' ? '已保存待确认,原安排尚未改变' : result.status === 'UNCHANGED' ? '安排没有变化' : result.status === 'WITHDRAWN' ? '已撤回变化,原安排保留' : '本次处理已保存' }}</h4>
|
||||
<p v-for="(step, index) in result.steps" :key="index">{{ step.message }}</p>
|
||||
<p v-if="result.nextActions.includes('REVIEW_TRANSPORT')">下一步:核对当前收货时段及车辆安排;本次保存不代表可以发车。</p>
|
||||
<el-button v-if="result.nextActions.includes('BOOK_LATER_BATCH') && laterBatch" :type="result.nextActions[0] === 'BOOK_LATER_BATCH' ? 'primary' : 'default'" :disabled="locked" @click="emit('appointment', laterBatch.appointmentNo)">继续给后送批次约仓</el-button>
|
||||
<template v-if="result.nextActions.includes('REVIEW_TRANSPORT')"><el-button v-for="(number, index) in currentTrips" :key="number" :type="index === 0 && result.nextActions[0] === 'REVIEW_TRANSPORT' ? 'primary' : 'default'" :disabled="locked || !access.can('platform:fleet:trip:list')" @click="emit('external', `/overseas-master-data/trips/${encodeURIComponent(number)}`)">核对本车发车安排 · {{ number }}</el-button></template>
|
||||
<el-button v-if="result.status === 'WAITING' && context.pendingChanges.find(row => row.changeKey === result?.changeKey)" type="primary" :disabled="locked" @click="resume(context.pendingChanges.find(row => row.changeKey === result?.changeKey)!)">继续登记仓库回复</el-button>
|
||||
<template v-if="mode === 'refresh'"><p>本客户资料已处理,可继续保存本客户计划或登记正式预约。</p><el-button :disabled="locked || !planEligible(customer)" @click="openPlan(customer)">继续本客户目标 / 未约成</el-button><el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">核对未确认客户并登记正式预约</el-button></template><p v-if="mode === 'plan'">本客户计划已处理。可返回范围概览核对客户计划,或在本客户事项继续记录回复与跟进。</p><div v-for="batch in (['plan', 'refresh'].includes(mode) ? [] : result.batches).filter(row => row.items.some(item => item.packageCount == null || item.packageCount > 0))" :key="batch.batchKey"><p>{{ batch.appointmentNo }} · {{ batch.plannedDate || '日期未定' }}</p><el-button v-if="batch.batchKey" :disabled="locked" @click="openBatch(batch.batchKey)">查看本批当前去向</el-button><el-button :disabled="locked" @click="emit('appointment', batch.appointmentNo)">继续本批预约</el-button></div>
|
||||
<el-button v-for="(agreement, index) in result.agreements.filter(row => row.status === 'CANCELLED')" :key="agreement.agreementKey" :type="index === 0 && result.nextActions[0] === 'REBOOK_GROUP' ? 'primary' : 'default'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button>
|
||||
</section>
|
||||
<template v-else-if="mode === 'overview'">
|
||||
<h4>各客户计划与货物调整</h4><p>先保存待确认变化,收到仓库明确回复后再生效;本次先送量允许为 0。</p>
|
||||
<div v-for="(id, index) in customers" :key="id"><b>{{ customerName(id) || '—' }} · 客户范围 {{ index + 1 }}</b><p v-for="item in context.batch.items.filter(row => row.customerId === id)" :key="item.id">{{ item.inboundCode || '原票业务号未提供' }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p v-if="customerPlan(id)">本客户计划:{{ customerPlan(id)!.details.plannedDate || '日期未定' }} · {{ customerPlan(id)!.details.appointmentStatus === 'FAILED' ? '未约成' : '等待确认' }} · {{ customerPlan(id)!.details.appointmentRemark || '—' }}</p><el-button :disabled="locked || !edit || !refreshEligible(id)" @click="openCargoRefresh(id)">核对并采用本客户最新货物资料</el-button><el-button :disabled="locked || !edit || !planEligible(id)" @click="openPlan(id)">保存目标 / 未约成</el-button><el-button :disabled="locked || !edit || !remove || !context.allowedActions.includes('PROPOSE_CUSTOMER_CHANGE')" @click="adjust(id)">调整本客户先送 / 后送</el-button></div>
|
||||
<h4>待仓库回复</h4><div v-for="pendingChange in context.pendingChanges.filter(row => row.status === 'WAITING')" :key="pendingChange.changeKey"><p>{{ pendingChange.intent.reason }} · 负责人 {{ pendingChange.followup.ownerName || '—' }} · 跟进 {{ pendingChange.followup.nextFollowupDate || '待安排' }}</p><el-button :disabled="locked" @click="resume(pendingChange)">继续本次变化</el-button></div>
|
||||
<h4>整份预约范围</h4><div v-for="agreement in context.agreements" :key="agreement.agreementKey"><p>{{ agreement.details.confirmationNo || '约号未提供' }} · {{ agreement.status === 'ACTIVE' ? '有效' : agreement.status === 'CANCELLED' ? '已取消' : '历史' }} · {{ agreement.details.appointmentAt || '时段未定' }} → {{ agreement.details.appointmentEndAt || '结束未定' }} · {{ agreement.timezone || '时区待核实' }}</p><ReceivingScope :members="agreement.members" /><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'reschedule')">核对整份改期范围</el-button><el-button v-if="agreement.status === 'ACTIVE'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'cancel')">核对整份取消范围</el-button><el-button v-if="agreement.status === 'CANCELLED'" :disabled="locked" @click="openGroup(agreement.agreementKey, 'book')">重新预约这组货物</el-button></div>
|
||||
<ReceivingScope :members="[]" :transport="context.transport" />
|
||||
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
|
||||
</template>
|
||||
<form v-else @submit.prevent="save()">
|
||||
<fieldset :disabled="locked || !formWritable">
|
||||
<template v-if="mode === 'refresh'">
|
||||
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
|
||||
<p v-if="cargoLoading">正在读取本客户各票最新核实资料…</p>
|
||||
<LoadErrorState v-if="cargoError" title="最新货物核实资料读取失败" :message="cargoError.message" :loading="cargoLoading" @retry="loadCargo" />
|
||||
<div v-for="(row, index) in cargoContexts" :key="index"><b>{{ row.source.inboundCode || row.source.bookingNo || '—' }}</b><p>本批 {{ customerItems[index]?.packageCount ?? '待核实' }} 箱 · 最新核实 {{ row.verification?.packageCount ?? '待核实' }} 箱 · 当前有效 {{ row.effectivePackages ?? '待核实' }} 箱 · 已分配 {{ row.allocatedPackages }} 箱</p><p>最新货件号:{{ row.verification?.shipmentReferences.join('、') || '—' }}</p></div>
|
||||
<p v-for="problem in cargoProblems" :key="problem" role="status">{{ problem }}</p>
|
||||
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
|
||||
</template>
|
||||
<template v-if="mode === 'plan'">
|
||||
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划<InlineHelp title="客户计划说明"><p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p></InlineHelp></h4>
|
||||
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
|
||||
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
|
||||
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
|
||||
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
|
||||
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
|
||||
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
|
||||
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
|
||||
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
||||
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
|
||||
|
||||
</template>
|
||||
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
|
||||
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
|
||||
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
|
||||
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
|
||||
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" /></label></template>
|
||||
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
|
||||
</fieldset>
|
||||
<el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button>
|
||||
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
|
||||
</form>
|
||||
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn() } }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(WorkSharedTripForm, { props: { dispatchNo: 'DS-60', canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, DispatchWorkflowDialog: { name: 'DispatchWorkflowDialog', props: ['modelValue', 'dispatchNo', 'action'], template: '<div data-add-cargo />' }, LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}<button @click="$emit(\'retry\')">重试</button></p>' }, ApiErrorMeta: true } } }) }
|
||||
function detail(no = 'DS-60', allowed = true) { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, counts: { packageCount: 60 }, vehicleInfo: { vehicle: null, driver: null }, actions: { ADD_CARGO: { allowed, blockCode: allowed ? null : 'PICKED', blockReason: allowed ? null : '货物已开始真实拣货' } } } as DispatchDetail }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('opens the native add-cargo workflow for the same dispatch instead of asking for another customer dispatch', async () => {
|
||||
const wrapper = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-60'); expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('60 箱'); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }); expect(dialog.props()).toMatchObject({ modelValue: true, dispatchNo: 'DS-60', action: 'ADD_CARGO' }); expect(await guard()).toBe(false)
|
||||
dialog.vm.$emit('saved'); await flushPromises(); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it('honors native stage/gate and permissions and rejects a different returned business number', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail('DS-60', false), stage: 'LOADING' }); const scanned = render(); await flushPromises(); expect(button(scanned, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(scanned.text()).toContain('货物已开始真实拣货'); scanned.unmount()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
|
||||
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
|
||||
})
|
||||
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
|
||||
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
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()
|
||||
})
|
||||
it('retries lookup as a read and keeps the original detail navigation', async () => {
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('来源已变化,请重新核对')
|
||||
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
||||
recovery.pending.value!.attempted = true
|
||||
const completed: CommandResult = {
|
||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
||||
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()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源版本需重新核对')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('来源版本需重新核对')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { DispatchWorkflowDialog, DispatchInputError, getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
|
||||
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
|
||||
watch(busy, value => emit('busy', value), { immediate: true })
|
||||
useWorkDraft(computed(() => false), busy)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
const current = ++generation; detail.value = null; error.value = null
|
||||
if (!readable.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const value = await getDispatch(props.dispatchNo)
|
||||
if (current !== generation) return
|
||||
if (value.dispatchNo !== props.dispatchNo) throw new DispatchInputError('读取结果与原排车单不一致,请重新核对')
|
||||
detail.value = value
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function 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 rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="为原排车添加同仓货物">
|
||||
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
|
||||
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
|
||||
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<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>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FbaAppointmentBatchContext } from './batchApi'
|
||||
export function batchContext(number = 'AP-001', key = 'FBA_APPOINTMENT:1'): FbaAppointmentBatchContext {
|
||||
return { appointment: { id: '1', appointmentNo: number, batchKey: key, version: 3, appointmentStatus: 'PENDING', originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', selectedCount: 2, replacementAppointmentNos: [], replacementBatchKeys: [], items: [
|
||||
{ id: 'item-1', customerId: 'customer-a', sourceKey: 'ORDER:1', inboundCode: 'IN-A', fbaWarehouseCode: 'ONT8', packageCount: 70, shipmentReferences: ['FBA-A'] },
|
||||
{ id: 'item-2', customerId: 'customer-b', sourceKey: 'BOOKING:2', inboundCode: 'IN-B', fbaWarehouseCode: 'ONT8', packageCount: 30, shipmentReferences: ['FBA-B'] },
|
||||
] }, sources: [
|
||||
const appointment: FbaAppointmentBatchContext['appointment'] = { id: '1', appointmentNo: number, batchKey: key, version: 3, appointmentStatus: 'PENDING', originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', selectedCount: 2, replacementAppointmentNos: [], replacementBatchKeys: [], items: [
|
||||
{ id: 'item-1', customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', inboundCode: 'IN-A', fbaWarehouseCode: 'ONT8', packageCount: 70, shipmentReferences: ['FBA-A'] },
|
||||
{ id: 'item-2', customerId: 'customer-b', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', inboundCode: 'IN-B', fbaWarehouseCode: 'ONT8', packageCount: 30, shipmentReferences: ['FBA-B'] },
|
||||
] }
|
||||
return { appointment, originalSourceRefs: appointment.items.map(item => ({ customerId: item.customerId, sourceKey: item.sourceKey, destinationKey: item.destinationKey! })), currentBatches: [appointment], sources: [
|
||||
{ customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70, originalPackages: 100, sourceVersion: 'source-a-v2', cargoVerificationVersion: 4, shipmentReferences: ['FBA-A'] },
|
||||
{ customerId: 'customer-b', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 30, originalPackages: null, sourceVersion: null, cargoVerificationVersion: 2, shipmentReferences: ['FBA-B'] },
|
||||
], allowedActions: ['VERIFY_CARGO', 'SPLIT', 'MERGE'], blockReasons: [] }
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface BatchSourceAllocation {
|
||||
customerId: string; sourceKey: string; destinationKey: string; packageCount: number | null
|
||||
originalPackages: number | null; sourceVersion: string | null; cargoVerificationVersion: number | null; shipmentReferences: string[]
|
||||
}
|
||||
export interface FbaAppointmentBatchContext { appointment: FbaAppointmentDto; sources: BatchSourceAllocation[]; allowedActions: Array<'VERIFY_CARGO' | 'SPLIT' | 'MERGE'>; blockReasons: string[] }
|
||||
export interface FbaAppointmentBatchContext { appointment: FbaAppointmentDto; originalSourceRefs: Array<{ customerId: string; sourceKey: string; destinationKey: string }>; currentBatches: FbaAppointmentDto[]; sources: BatchSourceAllocation[]; allowedActions: Array<'VERIFY_CARGO' | 'SPLIT' | 'MERGE'>; blockReasons: string[] }
|
||||
export interface BatchAllocation { customerId: string; sourceKey: string; packageCount: number; expectedSourceVersion: string | null; expectedVerificationVersion: number | null }
|
||||
export interface RepartitionFbaAppointmentsRequest {
|
||||
originals: Array<{ appointmentNo: string; version: number }>
|
||||
|
||||
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest'
|
||||
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
|
||||
|
||||
describe('appointment calendar', () => {
|
||||
it('shows six complete Monday-first weeks across year boundaries', () => {
|
||||
const dates = calendarDates('2027-01-15', 'month')
|
||||
expect(dates).toHaveLength(42)
|
||||
expect(dates[0]).toBe('2026-12-28')
|
||||
expect(dates.at(-1)).toBe('2027-02-07')
|
||||
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
|
||||
it.each([
|
||||
['2026-10-19', '2026-09-28', '2026-11-02', 35],
|
||||
['2027-01-15', '2026-12-28', '2027-02-01', 35],
|
||||
['2027-02-15', '2027-02-01', '2027-03-01', 28],
|
||||
['2026-08-15', '2026-07-27', '2026-09-07', 42],
|
||||
] as const)('shows only the complete weeks needed for %s', (anchor, from, to, count) => {
|
||||
const dates = calendarDates(anchor, 'month')
|
||||
expect(dates).toHaveLength(count)
|
||||
expect(dates[0]).toBe(from)
|
||||
expect(dates.at(-1)).toBe(addDays(to, -1))
|
||||
expect(calendarRange(anchor, 'month')).toEqual({ from, to })
|
||||
})
|
||||
|
||||
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
|
||||
|
||||
@@ -29,16 +29,19 @@ export function addDays(value: string, days: number): string {
|
||||
|
||||
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
|
||||
const date = parseDate(anchor)
|
||||
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1, 12)
|
||||
if (mode === 'month') date.setDate(1)
|
||||
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
|
||||
const from = dateKey(date)
|
||||
// A half-open interval also works directly with database timestamps.
|
||||
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
|
||||
// 月视图只补齐月末所在周,不追加整周的下月日期;结束日期仍为开区间。
|
||||
return { from, to: mode === 'month' ? addDays(dateKey(nextMonth), (8 - nextMonth.getDay()) % 7) : addDays(from, 7) }
|
||||
}
|
||||
|
||||
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
|
||||
const { from } = calendarRange(anchor, mode)
|
||||
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
|
||||
const { from, to } = calendarRange(anchor, mode)
|
||||
const dates: string[] = []
|
||||
for (let date = from; date < to; date = addDays(date, 1)) dates.push(date)
|
||||
return dates
|
||||
}
|
||||
|
||||
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
|
||||
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
import { listPlanningCargo, listPlanningDestinations, listPlanningContainers, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
|
||||
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
|
||||
@@ -20,6 +20,15 @@ it('keeps basic container listing on its own endpoint rather than requiring impa
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
it('uses authoritative destination options and sends the selected destination to server pagination', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:['ABE4','ONT8']})
|
||||
expect(await listPlanningDestinations()).toEqual(['ABE4','ONT8'])
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/cargo/destinations',{signal:undefined})
|
||||
vi.mocked(http.get).mockResolvedValueOnce({data:{records:[],page:2,size:20,total:0,pages:0}})
|
||||
await listPlanningCargo({page:2,size:20,keyword:'IN-A',destination:'ONT8'})
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-planning/cargo',{params:{page:2,size:20,keyword:'IN-A',destination:'ONT8'},signal:undefined})
|
||||
})
|
||||
it('resolves an order through the real canonical-source endpoint without fabricating an ORDER alias', async () => {
|
||||
vi.mocked(http.get).mockClear()
|
||||
vi.mocked(http.get).mockResolvedValue({data:{source:{sourceKey:'BOOKING:B-1'},coverage:{appointments:[]}}})
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface PlanningCargo {
|
||||
mixedDestinations: boolean
|
||||
}
|
||||
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string; destination?: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
|
||||
)
|
||||
@@ -39,6 +39,12 @@ export async function listPlanningCargo(query: { page: number; size: number; key
|
||||
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
|
||||
}
|
||||
|
||||
export async function listPlanningDestinations(signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo/destinations', { signal },
|
||||
))
|
||||
}
|
||||
|
||||
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
|
||||
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
|
||||
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { refreshReceivingCargo, getReceivingExecutionScope, saveReceivingPlan, getReceivingContext, getReceivingGroup, proposeReceivingChange, resolveReceivingChange, bookReceiving, changeReceivingGroup } from './receivingApi'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(http.get).mockResolvedValue({ data: {} }); vi.mocked(http.post).mockResolvedValue({ data: {} }) })
|
||||
it('uses the producer receiving paths and encodes opaque stable identities', async () => {
|
||||
await getReceivingContext('FBA_APPOINTMENT:1'); await getReceivingGroup('GROUP/1')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/appointment-receiving/batches/FBA_APPOINTMENT%3A1')
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1')
|
||||
const propose = { idempotencyKey: 'unchanged-key', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'v1', expectedVerificationVersion: 1 }], laterDate: null, ownerId: 'account-owner', nextFollowupDate: null, reason: '全部后送', transportVersion: 'scope-v1' } }
|
||||
await proposeReceivingChange(propose)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/changes', propose)
|
||||
const resolve = { idempotencyKey: 'resolve-key', version: 0, decision: 'CONFIRM' as const, warehouseEvidence: '明确回复', transportVersion: 'scope-v2' }
|
||||
await resolveReceivingChange('CH/1', resolve)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/changes/CH%2F1/resolve', resolve)
|
||||
const book = { idempotencyKey: 'book-key', bookings: [], cancelledAgreementKey: 'GROUP/1', cancelledAgreementVersion: 2, executionVersion: 'scope-v2' }
|
||||
await bookReceiving(book)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/bookings', book)
|
||||
const cancel = { idempotencyKey: 'cancel-key', version: 3, action: 'CANCEL' as const, details: null, warehouseEvidence: '取消依据', transportVersion: 'scope-v3' }
|
||||
await changeReceivingGroup('GROUP/1', cancel)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1/cancel', cancel)
|
||||
await changeReceivingGroup('GROUP/1', { ...cancel, action: 'RESCHEDULE' })
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/appointment-receiving/agreements/GROUP%2F1/reschedule', expect.objectContaining({ action: 'RESCHEDULE' }))
|
||||
})
|
||||
|
||||
it('posts a customer-scoped ordinary plan with execution version and no formal confirmation', async () => {
|
||||
const request = { idempotencyKey: 'plan-1', member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 4, customerId: 'customer-b' }, executionVersion: 'exec-2', details: { plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentStatus: 'FAILED' as const, appointmentRemark: '没有时段', confirmationNo: null, appointmentAt: null, appointmentEndAt: null, startOffset: null, endOffset: null } }
|
||||
await saveReceivingPlan(request)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/plans', request)
|
||||
})
|
||||
|
||||
it('reads execution eligibility for the explicitly selected members without including guessed versions', async () => {
|
||||
const members = [{ batchKey: 'FBA_APPOINTMENT:2', customerId: 'customer-b' }]
|
||||
const scope = { version: 'only-B', members: [] }
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ data: scope })
|
||||
expect(await getReceivingExecutionScope(members)).toEqual(scope)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/execution-scope', { members })
|
||||
})
|
||||
|
||||
it('adopts latest customer cargo versions without including quantity mutations or confirmation fields', async () => {
|
||||
const request = { idempotencyKey: 'adopt-b', member: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 4, customerId: 'customer-b' }, executionVersion: 'exec-current', sources: [{ sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', expectedSourceVersion: 'latest', expectedVerificationVersion: 2 }] }
|
||||
await refreshReceivingCargo(request)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-receiving/cargo-refresh', request)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
|
||||
export interface ReceivingSource { sourceKey: string; destinationKey: string; packageCount: number; sourceVersion: string; verificationVersion: number | null; inboundCode: string | null }
|
||||
export interface ReceivingMember { batchKey: string; appointmentNo: string; batchVersion: number; customerId: string; sources: ReceivingSource[]; customerName: string | null }
|
||||
export interface ReceivingAgreement { agreementKey: string; version: number; status: string; destinationKey: string; details: FbaAppointmentDetailsPayload; timezone: string | null; members: ReceivingMember[]; warehouseEvidence: string }
|
||||
export interface ReceivingTransport { version: string; vehicles: Array<{ tripNo: string | null; version: number | null; dispatchNo: string | null; customerId: string; batchKey: string; status: string; packageCount: number }>; blockReasons: string[] }
|
||||
export interface ReceivingQuantity { sourceKey: string; destinationKey: string; retainedPackages: number; expectedSourceVersion: string; expectedVerificationVersion: number | null }
|
||||
export interface ReceivingIntent { batchKey: string; batchVersion: number; customerId: string; quantities: ReceivingQuantity[]; laterDate: string | null; ownerId: string; nextFollowupDate: string | null; reason: string; transportVersion: string }
|
||||
export interface ReceivingExecutionScope { version: string; members: Array<{ member: { batchKey: string; customerId: string }; completed: boolean; canRebook: boolean; reason: string | null }> }
|
||||
export interface ReceivingPending { changeKey: string; version: number; status: string; intent: ReceivingIntent; originalMembers: ReceivingMember[]; originalAgreements: ReceivingAgreement[]; followup: { ownerId: string; ownerName: string | null; nextFollowupDate: string | null } }
|
||||
export interface ReceivingCustomerPlan { batchKey: string; customerId: string; details: FbaAppointmentDetailsPayload; timezone: string | null }
|
||||
export interface ReceivingCargoRefresh { idempotencyKey: string; member: Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>; sources: Array<{ sourceKey: string; destinationKey: string; expectedSourceVersion: string; expectedVerificationVersion: number | null }>; executionVersion: string }
|
||||
export interface ReceivingPlan { idempotencyKey: string; member: Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>; details: FbaAppointmentDetailsPayload; executionVersion: string }
|
||||
export interface ReceivingContext { batch: FbaAppointmentDto; plans: ReceivingCustomerPlan[]; agreements: ReceivingAgreement[]; pendingChanges: ReceivingPending[]; transport: ReceivingTransport; executionScope: ReceivingExecutionScope; allowedActions: string[]; blockReasons: string[] }
|
||||
export interface ReceivingGroup { agreement: ReceivingAgreement; eligibleMembers: ReceivingMember[]; excludedMembers: Array<{ member: ReceivingMember; reason: string; currentAgreementKey: string | null }>; transport: ReceivingTransport; executionScope: ReceivingExecutionScope }
|
||||
export interface ReceivingResult { changeKey: string | null; status: string; batches: FbaAppointmentDto[]; agreements: ReceivingAgreement[]; steps: Array<{ step: string; status: string; targetKey: string; message: string }>; nextActions: string[] }
|
||||
export interface ReceivingPropose { idempotencyKey: string; intent: ReceivingIntent }
|
||||
export interface ReceivingResolve { idempotencyKey: string; version: number; decision: 'CONFIRM' | 'WITHDRAW'; warehouseEvidence: string | null; transportVersion: string }
|
||||
export interface ReceivingBooking { members: Array<Pick<ReceivingMember, 'batchKey' | 'batchVersion' | 'customerId'>>; details: FbaAppointmentDetailsPayload; warehouseEvidence: string }
|
||||
export interface ReceivingBook { idempotencyKey: string; bookings: ReceivingBooking[]; cancelledAgreementKey: string | null; cancelledAgreementVersion: number | null; executionVersion: string }
|
||||
export interface ReceivingGroupChange { idempotencyKey: string; version: number; action: 'RESCHEDULE' | 'CANCEL'; details: FbaAppointmentDetailsPayload | null; warehouseEvidence: string; transportVersion: string }
|
||||
const base = '/platform/v1/last-leg/appointment-receiving'
|
||||
export async function getReceivingContext(batchKey: string) { return unwrapApiResponse(await http.get<ApiEnvelope<ReceivingContext>, ApiEnvelope<ReceivingContext>>(`${base}/batches/${encodeURIComponent(batchKey)}`)) }
|
||||
export async function getReceivingGroup(agreementKey: string) { return unwrapApiResponse(await http.get<ApiEnvelope<ReceivingGroup>, ApiEnvelope<ReceivingGroup>>(`${base}/agreements/${encodeURIComponent(agreementKey)}`)) }
|
||||
async function post(path: string, request: ReceivingCargoRefresh | ReceivingPlan | ReceivingPropose | ReceivingResolve | ReceivingBook | ReceivingGroupChange) { return unwrapApiResponse(await http.post<ApiEnvelope<ReceivingResult>, ApiEnvelope<ReceivingResult>>(`${base}${path}`, request)) }
|
||||
export const proposeReceivingChange = (request: ReceivingPropose) => post('/changes', request)
|
||||
export const resolveReceivingChange = (changeKey: string, request: ReceivingResolve) => post(`/changes/${encodeURIComponent(changeKey)}/resolve`, request)
|
||||
export const bookReceiving = (request: ReceivingBook) => post('/bookings', request)
|
||||
export const changeReceivingGroup = (agreementKey: string, request: ReceivingGroupChange) => post(`/agreements/${encodeURIComponent(agreementKey)}/${request.action === 'CANCEL' ? 'cancel' : 'reschedule'}`, request)
|
||||
|
||||
export const saveReceivingPlan = (request: ReceivingPlan) => post('/plans', request)
|
||||
|
||||
export async function getReceivingExecutionScope(members: Array<{ batchKey: string; customerId: string }>) { return unwrapApiResponse(await http.post<ApiEnvelope<ReceivingExecutionScope>, ApiEnvelope<ReceivingExecutionScope>>(`${base}/execution-scope`, { members })) }
|
||||
|
||||
export const refreshReceivingCargo = (request: ReceivingCargoRefresh) => post('/cargo-refresh', request)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectContainerSchedule } from './schedule'
|
||||
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
|
||||
const range = { from: '2026-11-01', to: '2026-12-01' }
|
||||
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
describe('four-type schedule projection', () => {
|
||||
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
it('adds two local calendar days across months without creating a time', () => {
|
||||
const events = projectContainerSchedule(sources, [warehouse], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
|
||||
})
|
||||
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
|
||||
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
|
||||
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
|
||||
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
|
||||
})
|
||||
it('keeps manually moved containers visible when their arrival is outside this month', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.date).toBe('2026-11-08')
|
||||
})
|
||||
it('does not replace the planned date with PDA actual arrival', () => {
|
||||
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
|
||||
expect(event.date).toBe('2026-11-05')
|
||||
expect(event.canEdit).toBe(false)
|
||||
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
|
||||
})
|
||||
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
|
||||
expect(events.map(row => row.date)).toEqual([null, null])
|
||||
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
|
||||
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
|
||||
})
|
||||
it('does not invent default plans when the authoritative warehouse query failed', () => {
|
||||
expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
|
||||
})
|
||||
it('uses the actual return date after completion while preserving the deadline in details', () => {
|
||||
const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
|
||||
expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { addDays } from './calendar'
|
||||
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
|
||||
|
||||
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
|
||||
export type ScheduleType = typeof scheduleTypes[number]
|
||||
export const scheduleLabels: Record<ScheduleType, string> = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
|
||||
export interface ScheduleEvent {
|
||||
key: string
|
||||
type: ScheduleType
|
||||
date: string | null
|
||||
time: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
sourcePath?: string
|
||||
warehouse?: WarehouseArrival
|
||||
canEdit?: boolean
|
||||
}
|
||||
const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
|
||||
const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
|
||||
|
||||
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
|
||||
const events: ScheduleEvent[] = []
|
||||
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
|
||||
for (const row of sources.containers) {
|
||||
const label = row.containerNo || row.cabinetNo || '—'
|
||||
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
|
||||
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
|
||||
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
|
||||
const date = row.arrivalAt?.slice(0, 10) || null
|
||||
if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
|
||||
time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
|
||||
subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
|
||||
fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
|
||||
// 读取失败时不能用默认值遮掉无法读取的人工安排。
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
|
||||
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
|
||||
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
|
||||
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
|
||||
canEdit: warehouse?.canEdit === true,
|
||||
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
|
||||
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
|
||||
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
|
||||
}
|
||||
// 人工改期可能跨月,与当前月份的到港记录无关。
|
||||
for (const row of warehouseMap.values()) {
|
||||
const date = row.plannedAt?.slice(0, 10) || null
|
||||
if (!date || !inRange(date, range)) continue
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
|
||||
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
|
||||
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
}
|
||||
for (const row of sources.emptyReturns) {
|
||||
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
|
||||
if (!at || !inRange(at.slice(0, 10), range)) continue
|
||||
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
|
||||
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
|
||||
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
|
||||
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
|
||||
{ label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface CalendarContainer {
|
||||
containerBookingId: string
|
||||
cabinetNo: string | null
|
||||
containerNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
arrivalAt: string | null
|
||||
arrivalType: 'ACTUAL' | 'ESTIMATED'
|
||||
}
|
||||
export interface CalendarReturn {
|
||||
containerBookingId: string
|
||||
containerNo: string | null
|
||||
fleetSupplierName: string | null
|
||||
actualEmptyReturnAt: string | null
|
||||
emptyReturnWarning: { status: string; deadlineAt: string | null }
|
||||
}
|
||||
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
|
||||
export interface WarehouseArrival {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
warehouseName: string | null
|
||||
warehouseCode: string | null
|
||||
plannedAt: string | null
|
||||
actualAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
canEdit: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
||||
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
||||
))
|
||||
}
|
||||
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||
))
|
||||
}
|
||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
|
||||
{ headers: { 'Idempotency-Key': key } },
|
||||
))
|
||||
}
|
||||
@@ -3,10 +3,25 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
|
||||
import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
|
||||
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
|
||||
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
||||
expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
|
||||
const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
|
||||
expect(missing.events).toEqual([])
|
||||
expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
|
||||
})
|
||||
it('counts a shared trip once and excludes cancelled arrangements', () => {
|
||||
const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
|
||||
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
|
||||
expect(result.events).toHaveLength(1)
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-SHARED 排车' })
|
||||
})
|
||||
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
|
||||
const localRange = {from:'2026-09-28',to:'2026-11-09'}
|
||||
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
|
||||
@@ -18,23 +33,23 @@ describe('location-based trip calendar', () => {
|
||||
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
|
||||
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
|
||||
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
|
||||
})
|
||||
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
|
||||
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
|
||||
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
})
|
||||
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
|
||||
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
|
||||
})
|
||||
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
|
||||
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
|
||||
const november = {from:'2026-11-01',to:'2026-12-01'}
|
||||
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
|
||||
@@ -43,9 +58,9 @@ describe('location-based trip calendar', () => {
|
||||
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
|
||||
})
|
||||
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
|
||||
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
|
||||
expect(result.events).toEqual([])
|
||||
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
|
||||
expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
tripNo: string
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -23,6 +23,7 @@ export const tripIssueLabels: Record<string, string> = {
|
||||
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
|
||||
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
|
||||
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
|
||||
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
|
||||
}
|
||||
export function tripIssues(row: TripCalendarEntry) {
|
||||
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
|
||||
@@ -54,9 +55,16 @@ function localStamp(value: string | null, zone: string | null) {
|
||||
} catch { return null }
|
||||
}
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
const seen = new Set<string>()
|
||||
for (const row of result.records) {
|
||||
if (!row.dispatchNo?.trim()) {
|
||||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
|
||||
}
|
||||
if (seen.has(row.dispatchNo)) continue
|
||||
seen.add(row.dispatchNo)
|
||||
if (row.status === 'CANCELLED') continue
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !occurrence) {
|
||||
@@ -65,11 +73,11 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const pending = () => new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409, undefined, 'request-sync', 'trace-sync')
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('waits only for the full-work synchronization guard and returns the fresh result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValueOnce(pending()).mockResolvedValue('fresh')
|
||||
const result = reader.read(load, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(reader.syncing.value).toBe(true)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(await result).toBe('fresh')
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it.each([new ApiResponseError('事项版本已变化', 'A0409', 409), new ApiResponseError('无权查看', 'A0403', 403), new Error('network')])('does not retry other failures: %s', async error => {
|
||||
const reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
await expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
})
|
||||
it('stops waiting at the limit and preserves diagnostic IDs on the original error', async () => {
|
||||
vi.useFakeTimers()
|
||||
const error = pending(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(error)
|
||||
const result = expect(reader.read(load, new AbortController().signal)).rejects.toBe(error)
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
await result
|
||||
expect(load).toHaveBeenCalledTimes(16)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(error.traceId).toBe('trace-sync')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('cancels a waiting read without sending another request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController(), reader = useWorkSyncRead(), load = vi.fn().mockRejectedValue(pending())
|
||||
const result = expect(reader.read(load, controller.signal)).rejects.toHaveProperty('name', 'AbortError')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
await result
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(reader.syncing.value).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
// 只等待工作清单同步守卫;版本冲突、权限失败和写请求均不重试。
|
||||
export function useWorkSyncRead() {
|
||||
const syncing = ref(false)
|
||||
let generation = 0
|
||||
async function read<T>(load: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
const current = ++generation
|
||||
syncing.value = false
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
try { return await load() }
|
||||
catch (cause) {
|
||||
const error = getApiErrorDetails(cause)
|
||||
if (error.code !== 'A0409' || error.message !== '预约批次或运输关联已变化,服务端正在协调完整工作清单' || attempt >= 15) throw cause
|
||||
if (current === generation) syncing.value = true
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 2000)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) { signal.removeEventListener('abort', abort); abort() }
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally { if (current === generation) syncing.value = false }
|
||||
}
|
||||
return { syncing, read }
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getWorkCalendar, getWorkSummary, getWorkItems, getWorkDetail, submitWorkCommand } from './workApi'
|
||||
import { getWorkCalendar, getWorkSummary, getWorkItems, getWorkDetail, submitWorkCommand, resolveBatchWork } from './workApi'
|
||||
import { getCargoVerification, saveCargoVerification } from './cargoVerificationApi'
|
||||
import { workCalendar, workDetail, workItem, workSummary } from './work.test-support'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('resolves a real batch and optional customer without manufacturing a work key', async () => {
|
||||
const resolved = { batchKey: 'FBA_APPOINTMENT:40', customerId: '9007199254740999', detail: workDetail(), candidates: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ data: resolved })
|
||||
expect(await resolveBatchWork(resolved.batchKey, resolved.customerId)).toEqual(resolved)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-work/items/resolve', { params: { batchKey: resolved.batchKey, customerId: resolved.customerId }, signal: undefined })
|
||||
})
|
||||
it('uses inclusive calendar dates and repeated type parameters with no duplicate API prefix', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: workCalendar })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -58,6 +58,7 @@ export type WorkCommandPayload =
|
||||
| { command: 'ACCEPT_RETURN'; payload: { handoffVersion: number; note: string | null } }
|
||||
export type WorkCommand = WorkCommandPayload & { expectedVersion: number; idempotencyKey: string }
|
||||
export interface WorkCommandResult { item: WorkItem; affectedWorkItemKeys: string[] }
|
||||
export interface BatchWorkResolution { batchKey: string; customerId: string | null; detail: WorkDetail | null; candidates: Array<{ customerId: string; workItemKey: string; batchNo: string | null }> }
|
||||
const base = '/platform/v1/last-leg/appointment-work'
|
||||
|
||||
// Repeated types parameters are part of v1; Axios' default types[] syntax is not.
|
||||
@@ -83,6 +84,9 @@ export async function getWorkItems(query: WorkItemsQuery, signal?: AbortSignal)
|
||||
export async function getWorkDetail(key: string, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<WorkDetail>, ApiEnvelope<WorkDetail>>(`${base}/items/${encodeURIComponent(key)}`, { signal }))
|
||||
}
|
||||
export async function resolveBatchWork(batchKey: string, customerId?: string, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<BatchWorkResolution>, ApiEnvelope<BatchWorkResolution>>(`${base}/items/resolve`, { params: { batchKey, customerId }, signal }))
|
||||
}
|
||||
export async function submitWorkCommand(key: string, command: WorkCommand) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WorkCommandResult>, ApiEnvelope<WorkCommandResult>>(`${base}/items/${encodeURIComponent(key)}/commands`, command))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 操作日志与审计模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/audit/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有独立页面**:审计日志是客户详情里的内嵌面板(`components/CustomerAuditLogsPanel.vue`),查询接口在 `api/platformAudit.ts`。
|
||||
- 后端查 `audit-service/`(`PlatformAuditLogController`、`AuditLogQueryService`);若是“操作人没被记录”,还要查采集侧 `common-audit/`(`BusinessAuditAspect.java`、`AuditEvent.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户详情内的操作日志面板、分页与详情 | `components/CustomerAuditLogsPanel.vue`(props:`customerId`、`customerName`) | `api/platformAudit.ts` 的 `listPlatformAuditLogs`、`getPlatformAuditLog`;`audit-service/` |
|
||||
| 日志字段展示、时间与文案映射 | `presentation.ts` | 服务端返回结构 |
|
||||
| 日志列表/详情的对外类型 | `index.ts` | `audit-service` DTO |
|
||||
| 操作人缺失、字段为空 | 面板空态与列定义 | 先查 `common-audit/` 是否采集该字段,再查 `audit-service/` 摄取 DTO 与表结构 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表不返回变更前后快照,详情才返回已脱敏 JSON;不要在列表里展开完整 payload。
|
||||
- 客户账号不得访问平台审计查询;面板只在平台侧客户详情内使用。
|
||||
- 审计字段缺失属于“采集侧漏采”或“查询侧未映射”,两者分属 `common-audit/` 与 `audit-service/`,不要只改前端展示。
|
||||
- 审计链路故障不得阻塞核心交易,前端也不得据此推断业务失败。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/audit/<目标测试>`;字段或查询变化同时核对 `audit-service` 的查询接口与表结构。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,17 +1,22 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail } from '../api/platformAudit'
|
||||
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
|
||||
|
||||
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
|
||||
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
|
||||
const detail: PlatformAuditLogDetail = { ...row, resourceId: '9007199254740993123', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
@@ -20,7 +25,7 @@ function mountPanel(permissions: string[]) {
|
||||
}
|
||||
|
||||
describe('CustomerAuditLogsPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getPlatformAuditLog).mockResolvedValue(detail); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
|
||||
it('queries logs with the current customer id', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
@@ -91,17 +96,24 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
})
|
||||
|
||||
it('loads detail and formats json snapshots', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const privateId = '9007199254740993123'
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, id: privateId }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, id: privateId, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith(privateId)
|
||||
expect(wrapper.text()).toContain('旧名称')
|
||||
expect(wrapper.text()).toContain('新名称')
|
||||
expect(wrapper.text()).toContain('入库测量审核通过')
|
||||
expect(wrapper.text()).toContain('入库测量')
|
||||
expect(wrapper.text()).toContain('头程履约服务')
|
||||
expect(wrapper.text()).toContain('更新')
|
||||
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '操作日志详情 · 柏悦物流', size: 'min(920px, 96vw)', showFooter: false })
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('req')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('trace')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain(privateId)
|
||||
expect(wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').attributes('aria-label')).not.toContain(privateId)
|
||||
})
|
||||
|
||||
it('loads the selected audit page', async () => {
|
||||
@@ -131,4 +143,100 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
it('preserves failure details and complete long or non-json snapshots as text', async () => {
|
||||
const longText = '长内容'.repeat(1200)
|
||||
const raw = '<script>原始内容</script>\n不是JSON'
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: '数据版本冲突', beforeData: JSON.stringify({ note: longText }), afterData: raw })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
const drawer = wrapper.findComponent(AppDrawer)
|
||||
expect(drawer.text()).toContain('操作失败')
|
||||
expect(drawer.get('[role="alert"]').text()).toContain('数据版本冲突')
|
||||
const snapshots = drawer.findAll('.density-code-block')
|
||||
expect(snapshots[0].text()).toBe(JSON.stringify({ note: longText }, null, 2))
|
||||
expect(snapshots[1].text()).toBe(raw)
|
||||
expect(drawer.find('script').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps missing snapshots compact and a zero duration visible', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: null, errorCode: 'OPERATION_CONFLICT', durationMs: 0, beforeData: '', afterData: null, requestId: null, traceId: null })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.density-code-block').map(snapshot => snapshot.text())).toEqual(['无', '无'])
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('0 ms')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain('链路标识')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('OPERATION_CONFLICT')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).toContain('操作失败,请查看诊断信息')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).not.toContain('OPERATION_CONFLICT')
|
||||
})
|
||||
|
||||
it('retries the selected log after a detail request failure', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(detail)
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('详情加载失败')
|
||||
await wrapper.findComponent(AppDrawer).get('.load-error-state button').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getPlatformAuditLog).mock.calls).toEqual([['1'], ['1']])
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('ignores a late %s from the previously selected log', async outcome => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
let fail!: (reason: Error) => void
|
||||
const previous = new Promise<PlatformAuditLogDetail>((resolve, reject) => { finish = resolve; fail = reject })
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row, { ...row, id: '2', description: '第二条记录' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockImplementation(id => id === '1' ? previous : Promise.resolve({ ...detail, id: '2', description: '第二条详情' }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
const actions = wrapper.findAll('.el-table__body button[aria-label^="查看操作日志:"]')
|
||||
await actions[0].trigger('click'); await flushPromises()
|
||||
await actions[1].trigger('click'); await flushPromises()
|
||||
if (outcome === 'resolve') finish({ ...detail, description: '不应重新展示的旧详情' })
|
||||
else fail(new Error('旧请求失败'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('loading')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('第二条详情')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('不应重新展示的旧详情')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes and clears pending detail when switching customers', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
await wrapper.setProps({ customerId: '9', customerName: '另一客户' }); await flushPromises()
|
||||
finish(detail); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(listPlatformAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ accountId: '9' }))
|
||||
})
|
||||
|
||||
it('ignores pending responses after closing and permits a fresh reopen', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent(AppDrawer).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
finish({ ...detail, description: '过期详情' }); await flushPromises()
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('过期详情')
|
||||
})
|
||||
|
||||
it('clears detail when audit permission is revoked', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
useAuthStore().permissions = []
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('暂无操作日志查看权限')
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ScrollText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -24,6 +24,9 @@ const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformAuditLogDetail | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedLogId = ref<string | null>(null)
|
||||
const detailTitle = computed(() => props.customerName.trim() ? `操作日志详情 · ${props.customerName.trim()}` : '操作日志详情')
|
||||
let detailRequestSerial = 0
|
||||
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
|
||||
const filters = ref<QueryModel>(emptyFilters())
|
||||
const appliedFilters = ref<QueryModel>(emptyFilters())
|
||||
@@ -80,7 +83,7 @@ async function load() {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:audit:list')) return
|
||||
if (!customerId || !access.can('platform:audit:list')) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformAuditLogs(toQuery(customerId))
|
||||
@@ -104,17 +107,45 @@ function applyFilters(model: QueryModel) {
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
detailOpen.value = true
|
||||
function clearDetail() {
|
||||
detailRequestSerial += 1
|
||||
selectedLogId.value = null
|
||||
detailOpen.value = false
|
||||
detailLoading.value = false
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const logId = selectedLogId.value
|
||||
const customerId = props.customerId.trim()
|
||||
if (!logId || !detailOpen.value || !access.can('platform:audit:list')) return
|
||||
const serial = ++detailRequestSerial
|
||||
const isCurrent = () => serial === detailRequestSerial && customerId === props.customerId.trim() && detailOpen.value
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await getPlatformAuditLog(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
try {
|
||||
const result = await getPlatformAuditLog(logId)
|
||||
if (isCurrent()) detail.value = result
|
||||
} catch (error) {
|
||||
if (isCurrent()) detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (isCurrent()) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => {
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
selectedLogId.value = row.id
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
watch(detailOpen, open => { if (!open) clearDetail() })
|
||||
onBeforeUnmount(() => { requestSerial += 1; detailRequestSerial += 1 })
|
||||
|
||||
watch([() => props.customerId, () => access.can('platform:audit:list')], () => {
|
||||
clearDetail()
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void load()
|
||||
@@ -133,7 +164,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
|
||||
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志:${auditEventLabel((row as PlatformAuditLogListItem).eventType)},${formatDate((row as PlatformAuditLogListItem).occurredAt)}`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
|
||||
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
|
||||
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
|
||||
@@ -145,29 +176,31 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<AppDrawer class="customer-audit-drawer admin-density-drawer" v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<section class="audit-detail-hero" :class="detail.success ? 'is-success' : 'is-failed'">
|
||||
<span class="result-icon" aria-hidden="true"><CircleCheck v-if="detail.success" :size="22" /><CircleX v-else :size="22" /></span>
|
||||
<div class="hero-copy"><span>事件类型</span><h3>{{ auditEventLabel(detail.eventType) }}</h3><p>{{ auditDescriptionLabel(detail.description) }}</p></div>
|
||||
<el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag>
|
||||
<AppDrawer class="customer-audit-drawer admin-density-drawer admin-density-drawer--detail" v-model="detailOpen" :title="detailTitle" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<div v-else-if="detail" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">{{ auditEventLabel(detail.eventType) }} <el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag></h3>
|
||||
<p class="audit-event-description">{{ auditDescriptionLabel(detail.description) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="audit-detail-grid" aria-label="操作日志基础信息">
|
||||
<div><span>操作人</span><strong>{{ auditOperatorLabel(detail.username) }}</strong></div>
|
||||
<div><span>发生时间</span><strong>{{ formatDate(detail.occurredAt) }}</strong></div>
|
||||
<div><span>来源服务</span><strong>{{ auditServiceLabel(detail.serviceName) }}</strong><small>{{ display(detail.serviceName) }}</small></div>
|
||||
<div><span>客户端 IP</span><strong>{{ display(detail.clientIp) }}</strong></div>
|
||||
<div><span>业务资源</span><strong>{{ auditResourceLabel(detail.resourceType) }}</strong><small>{{ display(detail.resourceId) }}</small></div>
|
||||
<div><span>处理耗时</span><strong>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</strong></div>
|
||||
<div class="wide request-cell"><span>请求地址</span><div><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></div></div>
|
||||
<div class="wide identity-cell"><span>请求标识</span><strong>{{ display(detail.requestId) }}</strong><small v-if="detail.traceId">链路标识:{{ detail.traceId }}</small></div>
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">操作信息</h3>
|
||||
<table class="density-table audit-detail-table" aria-label="操作日志基础信息"><tbody>
|
||||
<tr><th scope="row">操作人</th><td>{{ auditOperatorLabel(detail.username) }}</td><th scope="row">发生时间</th><td>{{ formatDate(detail.occurredAt) }}</td></tr>
|
||||
<tr><th scope="row">来源服务</th><td>{{ auditServiceLabel(detail.serviceName) }}<small class="audit-service-code density-dialog-note">{{ display(detail.serviceName) }}</small></td><th scope="row">客户端 IP</th><td>{{ display(detail.clientIp) }}</td></tr>
|
||||
<tr><th scope="row">业务资源</th><td>{{ auditResourceLabel(detail.resourceType) }}</td><th scope="row">处理耗时</th><td>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</td></tr>
|
||||
<tr><th scope="row">请求地址</th><td colspan="3"><span class="audit-request"><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></span></td></tr>
|
||||
<tr><th scope="row">请求标识</th><td colspan="3"><code>{{ display(detail.requestId) }}</code></td></tr>
|
||||
<tr v-if="!detail.success && detail.errorCode"><th scope="row">错误码(诊断)</th><td colspan="3"><code>{{ detail.errorCode }}</code></td></tr>
|
||||
<tr v-if="detail.traceId"><th scope="row">链路标识</th><td colspan="3"><code>{{ detail.traceId }}</code></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<el-alert v-if="!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
|
||||
<div class="snapshot-grid"><section><h3>操作前快照</h3><p>操作执行前保留的业务数据</p><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><p>操作执行后返回或保存的业务数据</p><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
|
||||
</template>
|
||||
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
|
||||
<div class="audit-snapshots">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -178,8 +211,13 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
|
||||
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
|
||||
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
|
||||
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
|
||||
.audit-event-description { margin:0; overflow-wrap:anywhere; }
|
||||
.audit-detail-table { table-layout:fixed; }
|
||||
.audit-detail-table th { width:92px; }
|
||||
.audit-service-code { display:block; }
|
||||
.audit-request { display:flex; align-items:center; gap:var(--admin-space-3); }
|
||||
.audit-request code { min-width:0; }
|
||||
.audit-snapshots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
|
||||
.audit-snapshots > .density-dialog-section { margin-bottom:0; }
|
||||
@media(max-width:720px) { .audit-snapshots { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 登录模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 模块只有一个页面 `pages/LoginView.vue`;认证状态、Token 刷新与路由守卫在 `../../core/auth/` 与 `../../shared/api/AGENTS.md`,本模块不复制这些逻辑。
|
||||
- 后端只查 `auth/`(登录、刷新、退出、改密)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 平台员工登录页、表单校验与错误提示 | `pages/LoginView.vue` | `../../core/auth/auth.ts` → `../../shared/api/http.ts`;`auth/` |
|
||||
| 登录态与路由跳转 | 页面不持有状态 | `../../core/auth/auth.ts`、`../../core/access/` |
|
||||
| 401 刷新与重放 | 页面不处理 | `../../shared/api/http.ts`、`../../shared/api/AGENTS.md` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 页面不读写 Token、不拼 `Authorization`、不自行持久化密码;令牌与刷新编排只在 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 认证失败提示按服务端返回的 `code` 与文案展示,不把 401/403 混为同一种提示。
|
||||
- 登录成功后跳转由路由守卫决定,页面不硬编码默认落地页。
|
||||
- 记住账号类能力只保存登录标识,不持久化密码或 Token。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或守卫变化同时核对 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Admin 货柜模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/container-loading/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
|
||||
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;接口、状态、金额或权限变化时再读详细索引命中行。
|
||||
- 后端只查直接消费方:`firstleg/`(`loading/`、`customs/container/`、`terminaldrayage/`、`oceanbooking/`)、`lastleg-service/`(柜级到仓与尾程)、`file/`、`print/`。纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 国内装柜列表、模拟柜、订舱关联 | `pages/ContainersView.vue`、`pages/DomesticContainersView.vue`;`container-loading/domestic` | `api/platformContainerLoading.ts`;`components/ContainerSimulationCreate.vue`、`components/OceanBookingLinkDialog.vue`;`firstleg/loading/` |
|
||||
| 送柜报关详情、装柜清单页签、报关、出仓 | `pages/DomesticContainerDetailView.vue`、共享 `pages/ContainerDetailView.vue`;`container-loading/domestic/:containerId` | `components/ContainerOverviewPanel.vue`、`components/ContainerCustomsPanel.vue`;`api/platformContainerCustoms.ts`;`firstleg/customs/container/`、`file/` |
|
||||
| 智能装柜工作台、柜次容量与分配 | `pages/SmartLoadingWorkbenchView.vue`;`container-loading/domestic/:containerId/smart-loading` | `api/platformContainerLoading.ts`;`../unified-orders/workflows/components/SmartLoadingWorkbench.vue`;`firstleg/service/SmartLoadingService.java` |
|
||||
| 全局智能装柜弹框、入仓码候选与箱数 | `pages/GlobalSmartLoadingView.vue`(内嵌,无独立路由)、`components/SmartLoadingDialog.vue`、`components/SmartLoadingCandidateInbounds.vue` | `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`smartLoadingPlanGroups.ts`;后端 `/inbound-candidates`、`/containers`、`/inbound-assignments` |
|
||||
| 海外清关提柜工作台、船节点、清关与 DO/PU | `pages/OverseasContainersView.vue`;`container-loading/overseas` | `api/platformContainerLoading.ts`;`firstleg/` 船节点与 `customs/`;`components/ContainerOverviewPanel.vue` |
|
||||
| 目的港码头拖车、车队与还空 | `pages/OverseasContainerDetailView.vue`(薄包装)、共享 `pages/ContainerDetailView.vue`(实际内容);`container-loading/overseas/:containerId` | `api/terminalDrayages.ts` 由共享详情调用;`firstleg/terminaldrayage/`(`TerminalDrayageService.java`、`DirectDeliveryService.java`) |
|
||||
| 整柜预计/实际到仓 | 列表与详情“到仓”列 | `api/containerArrivalTimes.ts`;`lastleg-service/` 柜级订单 |
|
||||
| 装柜照片与证据的展示/读取(上传在 PDA 与 Firstleg) | `components/ContainerOverviewPanel.vue` | 证据读取走 `file/`;生产与上传见 `firstleg/loading/service/CabinetLoadingEvidenceService.java` 与国内 PDA |
|
||||
| 海外柜费用 | `components/ContainerLastLegFeesPanel.vue` | `api/overseasContainerFees.ts`;`finance/`、`firstleg/` 费用来源 |
|
||||
|
||||
`pages/ContainerWorkbenchView.vue` 是工作台外壳,按权限在“国内装柜”“清关提柜”间切换;`pages/ContainersView.vue` 与 `ContainerDetailView.vue` 为共享实现,改两份前先确认是否同源。
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,写资格只取服务端 `allowedActions`;不用浏览器筛选、本地推导或柜内明细数量代替。
|
||||
- 创建柜次时自动生成唯一预装计划;订舱关联允许真实集装箱号为空,但**确认预装和创建 PDA 任务前必须补齐**,历史上已建缺号任务可在预装详情首次补录。
|
||||
- 还空截止、超时天数只展示服务端按权威卸柜完成时间加 72 小时计算的结果,前端不重算、不用拖车状态当阻断门禁。
|
||||
- 国内“送柜报关”只消费 `/containers/domestic`,海外“清关提柜”只消费 `/containers/overseas`;两端统一用 `containerBookingId`,禁止混用两套列表接口。
|
||||
- 智能装柜候选状态只作筛选展示,按钮资格以服务端 `canAssign` 为准;计划箱数只消费 `totalPackageCount/remainingPackageCount`,不从候选页或本地规格重算。
|
||||
- 前端不计算托数、不逐票建计划、不预占托盘;柜级转换必须原样回传服务端 `snapshotVersion/snapshotHash`,冲突后重新预览。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/container-loading/<目标测试>`;API 或类型变化同时运行 `pnpm typecheck`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`;离线检查不代替真实 HTTP 与人工截图验收。
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
|
||||
|
||||
@@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
|
||||
downloadSource: vi.fn(),
|
||||
downloadReceipt: vi.fn(),
|
||||
downloadFinal: vi.fn(),
|
||||
recordResult: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/platformContainerCustoms', () => ({
|
||||
@@ -27,6 +29,7 @@ vi.mock('../api/platformContainerCustoms', () => ({
|
||||
submitContainerCustomsToBroker: vi.fn(),
|
||||
sendContainerCustomsToBroker: api.sendToBroker,
|
||||
getDeclarationBatches: api.getDeclarationBatches,
|
||||
recordClearanceResult: api.recordResult,
|
||||
}))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
|
||||
@@ -77,6 +80,51 @@ describe('ContainerCustomsPanel', () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
|
||||
const batch: DeclarationBatch = {
|
||||
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
|
||||
batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
|
||||
customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
|
||||
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
|
||||
}
|
||||
api.getDeclarationBatches.mockResolvedValue([batch])
|
||||
api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
|
||||
} }).setupState
|
||||
state.clearanceResultType = 'OTHER'
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
|
||||
expect(dialog.props('lockWhileSubmitting')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.recordResult).not.toHaveBeenCalled()
|
||||
state.clearanceRemark = ' 已核实海关通知 '
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
const wrapper = await render('READY_TO_START')
|
||||
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(api.start).not.toHaveBeenCalled()
|
||||
confirm.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
|
||||
const reasons: Array<[ClearanceResultType, string]> = [
|
||||
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import {
|
||||
completeAllBuyContainerCustoms,
|
||||
createDeclarationBatch,
|
||||
@@ -113,13 +115,18 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function customsDialogTitle(action: string, batch?: DeclarationBatch | null) {
|
||||
const code = batch?.batchNo?.trim()
|
||||
return code ? `${action} · ${code}` : containerDialogTitle(action, customs.value)
|
||||
}
|
||||
|
||||
function declarationStatusLabel(value: DeclarationBatch['status']) {
|
||||
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
|
||||
}
|
||||
|
||||
async function createFormalDeclaration() {
|
||||
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
|
||||
@@ -128,7 +135,7 @@ async function createFormalDeclaration() {
|
||||
|
||||
async function submitFormalDeclaration(batch: DeclarationBatch) {
|
||||
try {
|
||||
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
|
||||
const answer = await ElMessageBox.prompt('请输入报关单号', customsDialogTitle('提交正式申报', batch), { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
|
||||
actionLoading.value = true; actionError.value = null
|
||||
await submitDeclarationBatch(batch, answer.value)
|
||||
await load(); ElMessage.success('正式申报已提交')
|
||||
@@ -163,7 +170,7 @@ async function recordFormalResult() {
|
||||
|
||||
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
|
||||
try {
|
||||
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
|
||||
const answer = await ElMessageBox.prompt('请输入处理说明', customsDialogTitle('处理海关结果', batch), { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
|
||||
actionLoading.value = true; actionError.value = null
|
||||
await resolveClearanceResult(result, batch.version, answer.value)
|
||||
await load(); ElMessage.success('海关结果已处理')
|
||||
@@ -176,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
|
||||
async function start() {
|
||||
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||
try {
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
} catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||
@@ -242,7 +249,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
|
||||
async function submitToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
|
||||
@@ -251,7 +258,7 @@ async function submitToBroker() {
|
||||
|
||||
async function sendToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
|
||||
@@ -260,7 +267,7 @@ async function sendToBroker() {
|
||||
|
||||
async function completeAllBuy() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', customsDialogTitle('完成全买单柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
@@ -291,7 +298,7 @@ onMounted(load)
|
||||
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<header class="customs-header">
|
||||
<div><h2>整柜报关</h2><p>资料、回执与完税文件按柜管理。</p></div>
|
||||
<div><h2>整柜报关</h2></div>
|
||||
<div v-if="customs" class="customs-actions">
|
||||
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
|
||||
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
|
||||
@@ -331,7 +338,7 @@ onMounted(load)
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>查看申报批次及海关处理结果。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
|
||||
<div v-else class="declaration-batch-list">
|
||||
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
|
||||
@@ -346,10 +353,10 @@ onMounted(load)
|
||||
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
|
||||
</template>
|
||||
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
|
||||
<el-dialog v-model="clearanceDialogVisible" class="admin-density-dialog" title="标记海关异常/结果" width="460px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="clearance-result-form">
|
||||
<AppDialog v-model="clearanceDialogVisible" class="admin-density-dialog" :title="customsDialogTitle('标记海关异常/结果', clearanceBatch)" width="460px" :close-on-click-modal="false" :lock-while-submitting="false" :confirm-loading="actionLoading" confirm-text="确认标记" @confirm="recordFormalResult">
|
||||
<el-form label-position="top" class="density-form density-form--flush-end">
|
||||
<el-form-item label="选择理由" required>
|
||||
<el-select v-model="clearanceResultType" class="clearance-result-select" placeholder="请选择理由">
|
||||
<el-select v-model="clearanceResultType" placeholder="请选择理由">
|
||||
<el-option v-for="option in clearanceResultOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -357,11 +364,10 @@ onMounted(load)
|
||||
<el-input v-model="clearanceRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="clearanceResultType === 'OTHER' ? '请填写具体情况' : '可填写短信内容或人工处理说明(可选)'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="clearanceDialogVisible = false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="recordFormalResult">确认标记</el-button></template>
|
||||
</el-dialog>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElFormItem, ElSelect } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [{ id: '21', supplierName: '供应商甲', category: 'TERMINAL_DRAYAGE' }] }))
|
||||
vi.mock('../api/overseasContainerFees', () => ({
|
||||
listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
|
||||
createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
|
||||
}))
|
||||
|
||||
async function setup(readonly = false) {
|
||||
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
|
||||
async function setup(readonly = false, realDialog = false, containerNo: string | null = 'CONT-99') {
|
||||
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', cabinetNo: 'CAB-99', containerNo, readonly }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : {
|
||||
AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '<section v-if="modelValue"><h3>{{ title }}</h3><slot /><button :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
|
||||
type FormState = { form: { customerId: string; settlementMode: string; direction: string; amount: number | null; businessDate: string; supplierId: string; paidAt: string; paymentReferenceNo: string }; dialogOpen: boolean }
|
||||
async function upload(wrapper: Awaited<ReturnType<typeof setup>>, name: string) {
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
|
||||
@@ -37,6 +38,60 @@ describe('尾程付款凭证', () => {
|
||||
mocks.download.mockResolvedValue(new Blob(['image']))
|
||||
})
|
||||
|
||||
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
|
||||
const wrapper = await setup(false, true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
|
||||
expect(dialog.props('title')).toBe('新增尾程费用 · CAB-99')
|
||||
const labels = () => dialog.findAllComponents(ElFormItem).map(item => item.props('label'))
|
||||
expect(labels()).toHaveLength(7)
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
state.form.amount = 120.25
|
||||
state.form.supplierId = '21'
|
||||
state.form.businessDate = '2026-10-19'
|
||||
state.form.paidAt = '2026-10-19T09:00:00'
|
||||
state.form.paymentReferenceNo = ' PAY-001 '
|
||||
await flushPromises()
|
||||
expect(labels()).toHaveLength(11)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(state.form.amount).toBe(120.25)
|
||||
await dialog.get('[aria-label="退出全屏"]').trigger('click')
|
||||
expect(state.form.paymentReferenceNo).toBe(' PAY-001 ')
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customerId: '11', containerBookingId: '99', containerNo: 'CONT-99', businessNo: 'CONT-99',
|
||||
amount: 120.25, supplierId: '21', settlementMode: 'DIRECT_PAID',
|
||||
paidAt: '2026-10-19T09:00:00', paymentReferenceNo: 'PAY-001', currency: 'CNY',
|
||||
}), expect.stringContaining('container-fee-create-'))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('调整的所属客户显示只读名称,应收费用隐藏供应商和付款字段', async () => {
|
||||
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', sourceVersion: 7, customerId: '11', customerName: '客户甲', feeItemCode: 'PORT_FEE', amount: 10, businessDate: '2026-10-19', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', paymentVoucherFileIds: [] }])
|
||||
const wrapper = await setup(false, true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '调整')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整尾程费用'))!
|
||||
const fields = dialog.findAllComponents(ElFormItem)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['客户', '费用项目', '费用方向', '业务日期', '金额(CNY)'])
|
||||
const customer = fields[0]!
|
||||
expect(customer.text()).toContain('客户甲')
|
||||
expect(customer.findComponent(ElSelect).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
|
||||
const wrapper = await setup(false, true, null)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '新增费用')!.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
|
||||
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
|
||||
const wrapper = await setup()
|
||||
@@ -48,7 +103,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('显示上传文件名,允许再次上传替换和移除', async () => {
|
||||
const wrapper = await setup()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
await flushPromises()
|
||||
@@ -66,7 +121,7 @@ describe('尾程付款凭证', () => {
|
||||
|
||||
it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
|
||||
const wrapper = await setup()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
await flushPromises()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -7,11 +8,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
|
||||
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
|
||||
|
||||
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
|
||||
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; cabinetNo?: string | null; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasContainerFeeSource[]>([])
|
||||
const customers = ref<OverseasContainerCustomer[]>([])
|
||||
@@ -146,17 +148,17 @@ async function uploadVoucher(event: Event) {
|
||||
}
|
||||
}
|
||||
async function submit(row: OverseasContainerFeeSource) {
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, containerDialogTitle('提交费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
|
||||
}
|
||||
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
|
||||
let reason: string | null = null
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', containerDialogTitle('驳回费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, containerDialogTitle('审核费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
|
||||
}
|
||||
async function voidFee(row: OverseasContainerFeeSource) {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', containerDialogTitle('作废费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
|
||||
}
|
||||
onMounted(load)
|
||||
@@ -164,7 +166,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="last-leg-fees admin-density">
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}<InlineHelp title="费用统计说明"><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></InlineHelp></h2></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
|
||||
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
|
||||
@@ -182,13 +184,24 @@ onMounted(load)
|
||||
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
|
||||
<AppDialog class="admin-density-dialog" 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">
|
||||
<el-form class="density-form" label-position="left">
|
||||
<table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE'"><td :colspan="form.settlementMode === 'DIRECT_PAID' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item></td><td v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label-position="left" label-width="110px" label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td :colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<AppDialog class="admin-density-dialog last-leg-voucher-dialog" v-model="previewOpen" :title="containerDialogTitle('付款凭证', props)" :fullscreenable="Boolean(previewUrl) && previewIsImage" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<p v-if="previewLoading">正在读取付款凭证…</p>
|
||||
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
|
||||
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
|
||||
<div v-else-if="previewUrl" class="density-dialog-section voucher-preview" data-tone="source"><p class="density-dialog-note">{{ previewName }}</p><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -201,10 +214,13 @@ onMounted(load)
|
||||
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
|
||||
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
|
||||
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
|
||||
.voucher-preview { display: grid; gap: var(--admin-space-5); }
|
||||
.voucher-preview { display: grid; gap: var(--admin-space-5); min-width: 0; overflow-wrap: anywhere; }
|
||||
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
|
||||
.fee-editor-table { table-layout: fixed; }
|
||||
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.last-leg-voucher-dialog.is-fullscreen .voucher-preview img { max-height: 80vh; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ContainerSimulationCreate from './ContainerSimulationCreate.vue'
|
||||
|
||||
// Node's supported CJS export keeps the real form validator's constructor intact.
|
||||
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ createContainerSimulation: vi.fn() }))
|
||||
const warehouse: PlatformWarehouseSummaryResource = { id: '8', tenantId: 'platform', warehouseCode: 'CN-01', internalCode: null, warehouseName: '测试国内仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }]) })
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('ContainerSimulationCreate opted-in form', () => {
|
||||
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
|
||||
const wrapper = mount(ContainerSimulationCreate, { props: { dense, loadWarehouseOptions: async () => [warehouse] }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(3)
|
||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||
await flushPromises()
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
|
||||
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
|
||||
await flushPromises()
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Container, Plus } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
@@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import type { ContainerSimulationCreateRequest } from '../types'
|
||||
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
|
||||
@@ -95,19 +95,54 @@ async function saveSimulation() {
|
||||
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="simulationOpen"
|
||||
class="simulation-create-dialog"
|
||||
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense }"
|
||||
title="新增模拟柜"
|
||||
width="min(800px, calc(100vw - 32px))"
|
||||
confirm-text="创建模拟柜"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="simulationSaving"
|
||||
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
|
||||
@confirm="saveSimulation"
|
||||
>
|
||||
<template #header>
|
||||
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
|
||||
</template>
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<div class="simulation-fields">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId">
|
||||
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
|
||||
<el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode">
|
||||
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
|
||||
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType" class="simulation-type">
|
||||
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
|
||||
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
|
||||
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
|
||||
.simulation-fields > * { min-width: 0; }
|
||||
.simulation-form :deep(.el-select) { width: 100%; }
|
||||
.simulation-type { grid-column: 1 / -1; }
|
||||
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
|
||||
@media (max-width: 600px) {
|
||||
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.simulation-type :deep(.el-select) { max-width: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
|
||||
@@ -35,6 +36,10 @@ describe('OceanBookingLinkDialog', () => {
|
||||
it('loads matching released-booking requirements when opened', async () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('关联已放舱订舱 · CAB-1')
|
||||
const identity = wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('label') === '柜次号')!
|
||||
expect(identity.text()).toContain('CAB-1')
|
||||
expect(identity.find('input').exists()).toBe(false)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
|
||||
wrapper.unmount()
|
||||
@@ -49,6 +54,9 @@ describe('OceanBookingLinkDialog', () => {
|
||||
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
|
||||
vm.candidateKey = '101:202'
|
||||
vm.containerNo = input
|
||||
await wrapper.findComponent(AppDialog).find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(vm.containerNo).toBe(input)
|
||||
expect(linkContainerOceanBooking).not.toHaveBeenCalled()
|
||||
await vm.save()
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
|
||||
|
||||
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
|
||||
@@ -98,18 +99,16 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||
</el-form>
|
||||
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
|
||||
<AppDialog class="admin-density-dialog" :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">
|
||||
<el-form class="density-form" label-position="left" label-width="160px">
|
||||
<table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
||||
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('SmartLoadingDialog', () => {
|
||||
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
|
||||
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
|
||||
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(document.querySelector('.smart-loading-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
|
||||
@@ -45,6 +46,7 @@ describe('SmartLoadingDialog', () => {
|
||||
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
|
||||
expect(source).toContain('height: 94vh; max-height: 94vh;')
|
||||
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
|
||||
expect(source).toContain('.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body')
|
||||
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
|
||||
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
|
||||
|
||||
@@ -8,7 +9,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
<template>
|
||||
<AppDialog
|
||||
v-model="open"
|
||||
class="smart-loading-dialog"
|
||||
class="smart-loading-dialog admin-density-dialog"
|
||||
title="智能装柜"
|
||||
width="min(1600px, 98vw)"
|
||||
fullscreenable
|
||||
@@ -23,7 +24,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
|
||||
<style scoped>
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
|
||||
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
|
||||
:global(.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
|
||||
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
|
||||
@media (max-width: 720px) {
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { containerDialogTitle } from './containerDialogTitle'
|
||||
|
||||
describe('containerDialogTitle business identity', () => {
|
||||
it('uses the stable cabinet number when both business identifiers exist', () => {
|
||||
expect(containerDialogTitle('编辑货柜资料', { cabinetNo: ' CB-001 ', containerNo: 'MSCU1234567' })).toBe('编辑货柜资料 · CB-001')
|
||||
})
|
||||
it('uses a real container number if the cabinet number is missing', () => {
|
||||
expect(containerDialogTitle('确认还柜', { cabinetNo: ' ', containerNo: ' MSCU1234567 ' })).toBe('确认还柜 · MSCU1234567')
|
||||
})
|
||||
it('never substitutes an internal ID for a missing business identifier', () => {
|
||||
const container = { id: 'private-container-id', cabinetNo: null, containerNo: ' ' }
|
||||
expect(containerDialogTitle('直送卸货登记', container)).toBe('直送卸货登记')
|
||||
expect(containerDialogTitle('直送卸货登记', null)).toBe('直送卸货登记')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
type ContainerIdentity = { cabinetNo?: string | null; containerNo?: string | null }
|
||||
|
||||
export function containerDialogTitle(action: string, container?: ContainerIdentity | null) {
|
||||
const code = container?.cabinetNo?.trim() || container?.containerNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
}
|
||||
+33
-9
@@ -93,14 +93,16 @@ describe('ContainerDetailView empty return', () => {
|
||||
const wrapper = await mountDetail()
|
||||
expect(getDirectDeliveries).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
|
||||
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
|
||||
}
|
||||
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
await flushPromises(); await dialog.find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.findComponent(ElDatePicker).props('modelValue')).toBe('2026-10-16T01:00:00')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
|
||||
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
expect(dialog.text()).not.toContain('9007199254740999')
|
||||
@@ -114,24 +116,46 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
const wrapper = await mountDetail()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
|
||||
expect(dialog.findAll('input, textarea')).toHaveLength(0)
|
||||
expect(dialog.text()).toContain('POD-001')
|
||||
expect(dialog.text()).toContain('America/Los_Angeles')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mislabel a cabinet number as a real container number or an unnamed fleet as unassigned', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const withoutNumber = { ...(await getContainer('9001')), containerNo: '' }
|
||||
const unnamedFleet = { ...(await getTerminalDrayage('9001')), containerNo: '', fleetSupplierId: 'private-supplier-id', fleetSupplierName: '' }
|
||||
vi.mocked(getContainer).mockResolvedValue(withoutNumber)
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue(unnamedFleet)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
const fields = dialog.findAllComponents(ElFormItem)
|
||||
const number = fields.find(field => field.props('label') === '真实集装箱号')!
|
||||
expect(number.text()).toBe('真实集装箱号—')
|
||||
const fleet = fields.find(field => field.props('label') === '当前车队')!
|
||||
expect(fleet.text()).toBe('当前车队—')
|
||||
expect(fleet.text()).not.toContain('private-supplier-id')
|
||||
expect(fleet.text()).not.toContain('尚未安排')
|
||||
})
|
||||
|
||||
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
|
||||
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
|
||||
const fleetButton = dialog.findAll('button').find(button => button.text() === '保存码头车队')!
|
||||
expect(fleetButton.element.closest('.el-dialog__footer')).not.toBeNull()
|
||||
await fleetButton.trigger('click'); await flushPromises()
|
||||
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
@@ -143,7 +167,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
|
||||
@@ -155,7 +179,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
|
||||
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
|
||||
@@ -175,7 +199,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
await flushPromises()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('已确认离仓')
|
||||
expect(wrapper.text()).toContain('整柜已确认离仓;')
|
||||
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
|
||||
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
|
||||
})
|
||||
|
||||
@@ -211,7 +235,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
expect(wrapper.text()).not.toContain('装柜完成')
|
||||
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('确认还柜'))!
|
||||
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
|
||||
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
|
||||
confirm.click()
|
||||
|
||||
@@ -75,7 +75,10 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
|
||||
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
|
||||
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
|
||||
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
|
||||
const firstLegFees = source.match(/<ContainerFeesPanel\b[^>]*\/>/)?.[0] || ''
|
||||
for (const attribute of ['title="国内段费用(头程)"', ':plan-id="overview.loadingPlanId"', ':container-booking-id="detail.id"', ':cabinet-no="detail.cabinetNo"', ':container-no="detail.containerNo"', ':customers="feeCustomers"', ':business-references="feeBusinessReferences"']) {
|
||||
expect(firstLegFees).toContain(attribute)
|
||||
}
|
||||
expect(source).not.toContain(':readonly="isOverseas"')
|
||||
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
|
||||
expect(source).toContain('title="目的港及海外段费用(尾程)"')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
@@ -172,7 +174,7 @@ async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
|
||||
async function leaveTerminal() {
|
||||
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
|
||||
onBeforeRouteLeave(leaveTerminal)
|
||||
@@ -186,7 +188,7 @@ const directPending = ref<{ containerId: string; requirementKey: string; body: D
|
||||
async function leaveDirect() {
|
||||
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
onBeforeRouteLeave(leaveDirect)
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
@@ -287,7 +289,7 @@ const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEm
|
||||
async function leaveEmptyReturn() {
|
||||
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
|
||||
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
|
||||
onBeforeRouteLeave(leaveEmptyReturn)
|
||||
@@ -486,7 +488,7 @@ async function confirmDeparture() {
|
||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||
outboundConfirming.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
} catch { outboundConfirming.value = false; return }
|
||||
outboundError.value = null
|
||||
try {
|
||||
@@ -691,7 +693,7 @@ onMounted(async () => {
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<ContainerPortSchedulePanel :container="detail" @saved="load" />
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
@@ -705,13 +707,13 @@ onMounted(async () => {
|
||||
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||||
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||
<template v-else-if="outboundDetail">
|
||||
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
|
||||
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
|
||||
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
|
||||
|
||||
|
||||
<div class="gate-list">
|
||||
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
|
||||
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
||||
@@ -736,49 +738,45 @@ onMounted(async () => {
|
||||
|
||||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
<AppDialog class="admin-density-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" 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="admin-density-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" 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="admin-density-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
|
||||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><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></section>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
|
||||
<section v-if="directSelected" class="density-dialog-section" :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"><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></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
|
||||
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
|
||||
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
|
||||
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
|
||||
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
|
||||
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
|
||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><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></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-workflow-table { table-layout: fixed; }
|
||||
.direct-remark { white-space: pre-wrap; }
|
||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('ContainersView', () => {
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
|
||||
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
|
||||
await returnDialog.find('textarea').setValue('还柜凭证已核对')
|
||||
const callsBefore = vi.mocked(listContainers).mock.calls.length
|
||||
@@ -176,7 +176,7 @@ describe('ContainersView', () => {
|
||||
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
|
||||
expect(wrapper.text()).toContain('MSCU-1')
|
||||
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
|
||||
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
|
||||
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
|
||||
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
|
||||
expect(getTerminalDrayage).not.toHaveBeenCalled()
|
||||
if (missing === 'historical unloaded snapshot') {
|
||||
@@ -200,7 +200,7 @@ describe('ContainersView', () => {
|
||||
const open = async () => {
|
||||
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
|
||||
}
|
||||
const first = await open()
|
||||
first.vm.$emit('update:modelValue', false)
|
||||
@@ -270,7 +270,19 @@ describe('ContainersView', () => {
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
expect(listSuppliers).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookings).toHaveBeenCalledTimes(1)
|
||||
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
|
||||
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
|
||||
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
|
||||
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(editorFields).toHaveLength(17)
|
||||
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
|
||||
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
|
||||
vessel.vm.$emit('update:modelValue', '编辑中的船名')
|
||||
await flushPromises()
|
||||
await editor.find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(vessel.props('modelValue')).toBe('编辑中的船名')
|
||||
expect(updateContainer).not.toHaveBeenCalled()
|
||||
editor.vm.$emit('update:modelValue', false)
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
@@ -417,9 +429,9 @@ describe('ContainersView', () => {
|
||||
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
|
||||
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
|
||||
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('admin-density-dialog')
|
||||
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
|
||||
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
@@ -588,7 +600,7 @@ describe('ContainersView', () => {
|
||||
expect(link).toBeDefined()
|
||||
link.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
|
||||
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
@@ -617,8 +629,8 @@ describe('ContainersView', () => {
|
||||
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
|
||||
expect(source).toContain('CONTAINER_SPECIFICATIONS')
|
||||
expect(simulationSource).toContain('selectSimulationContainerType')
|
||||
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
|
||||
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
|
||||
expect(simulationSource).not.toContain('创建后自动生成柜次号')
|
||||
expect(source).toContain("{{ form.cabinetNo || '—' }}")
|
||||
expect(source).not.toContain('v-model="form.cabinetNo"')
|
||||
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
|
||||
expect(source).not.toContain('openCreate')
|
||||
@@ -725,7 +737,7 @@ describe('ContainersView', () => {
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents(AppDialog)
|
||||
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
|
||||
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
|
||||
|
||||
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
@@ -147,7 +148,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
|
||||
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
|
||||
retryingEmail.value = mail.id
|
||||
try {
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', containerDialogTitle('重试邮件', emailTarget.value), { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await retryTerminalDrayageEmail(target.id, mail.id)
|
||||
ElMessage.success('邮件已重新加入发送队列')
|
||||
await openEmailHistory(target)
|
||||
@@ -197,7 +198,7 @@ const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:termi
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const editorTitle = computed(() => form.id ? containerDialogTitle('编辑货柜资料', form) : '新增')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
|
||||
interface PortOption { value: string; code: string | null; name: string | null }
|
||||
@@ -775,59 +776,52 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
|
||||
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(1080px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
||||
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
|
||||
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
|
||||
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
|
||||
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
|
||||
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
|
||||
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
|
||||
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
|
||||
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
|
||||
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
|
||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
|
||||
</el-form>
|
||||
<p class="density-dialog-note">货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</p>
|
||||
<section v-if="!warehouseError" class="density-dialog-section" data-tone="work">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
|
||||
<table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
|
||||
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item></td><td colspan="2"><el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="containerDialogTitle(drayageActionText(drayageAction), drayageTarget)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" :title="containerDialogTitle('车队邮件记录', emailTarget)" fullscreenable width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p class="density-dialog-note">集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
|
||||
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<section v-else class="density-dialog-section" data-tone="source"><DataTable :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
|
||||
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
|
||||
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
|
||||
</DataTable>
|
||||
</DataTable></section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" :title="containerDialogTitle('码头拖车记录', drayageTarget)" fullscreenable width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
|
||||
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
|
||||
<div v-else class="density-dialog-sections"><section v-if="drayageTarget && drayageFor(drayageTarget.id)" class="density-dialog-section" data-tone="summary"><el-descriptions :column="4" border class="density-descriptions"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions></section><section class="density-dialog-section" data-tone="source"><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></section></div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
|
||||
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
|
||||
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
@@ -118,6 +119,49 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
|
||||
}
|
||||
|
||||
describe('GlobalSmartLoadingView', () => {
|
||||
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
|
||||
if (unitType === 'SEALED_PALLET') {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate, unitType, palletId: '801', palletNo: 'DP-TEST-1', indivisible: true,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户甲', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户甲', availablePackageCount: 9, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}]))
|
||||
}
|
||||
vi.mocked(assignSmartLoadingItem).mockResolvedValue(plan)
|
||||
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(plan)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
||||
if (unitType === 'PACKAGE') {
|
||||
const input = popup.getComponent(ElInputNumber)
|
||||
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
|
||||
expect(input.props('max')).toBe(candidate.availablePackageCount)
|
||||
input.vm.$emit('update:modelValue', 4)
|
||||
} else {
|
||||
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
|
||||
expect(popup.get('details').text()).toContain('I181:9 箱')
|
||||
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
|
||||
}
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
if (unitType === 'PACKAGE') {
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 4, expectedVersion: plan.version })
|
||||
expect(assignSmartLoadingPallet).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(plan.id, { palletId: '801', expectedVersion: plan.version })
|
||||
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||||
@@ -139,6 +183,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
@@ -498,8 +543,13 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
|
||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -533,10 +583,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(add.classes()).not.toContain('is-disabled')
|
||||
await add.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
||||
expect(document.body.textContent).toContain('订单渠道:合德海卡')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
|
||||
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
||||
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
|
||||
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
||||
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
|
||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
@@ -876,6 +928,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await removeAllButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.any(String), '移除未拣明细 · CB-1', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
|
||||
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
@@ -1066,7 +1119,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)')
|
||||
expect(source).toContain('flex-wrap: nowrap; gap: 6px;')
|
||||
expect(source).toContain('height: 34px; padding: 0 10px;')
|
||||
expect(source).toContain('font-size: 13px; line-height: 32px;')
|
||||
expect(source).toContain('font-size: var(--admin-font-body); line-height: 32px;')
|
||||
expect(source).not.toContain('class="summary-band"')
|
||||
expect(source).toContain('class="capacity-grid"')
|
||||
expect(source).toContain('智能推荐')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
@@ -65,6 +66,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const noDraft = ref(false)
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||
const assignmentTitle = computed(() => {
|
||||
const candidate = selectedCandidate.value
|
||||
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
|
||||
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
})
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||
@@ -590,6 +597,10 @@ async function adjust(source: object) {
|
||||
ElMessage.success('箱数已调整')
|
||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||
}
|
||||
function workbenchDialogTitle(action: string) {
|
||||
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
|
||||
return code ? `${action} · ${code}` : action
|
||||
}
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value || !item.canRemove) return
|
||||
@@ -600,8 +611,8 @@ async function remove(source: object) {
|
||||
: sealedPallet
|
||||
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
|
||||
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -632,8 +643,8 @@ async function removeAll() {
|
||||
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
|
||||
if (!removableItems.length) return
|
||||
const itemCount = removableItems.length
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
|
||||
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -676,7 +687,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||
<component class="admin-density" :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||
<section class="global-loading-page">
|
||||
<div v-if="channelOptions.length" class="channel-summary-row">
|
||||
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
|
||||
@@ -743,9 +754,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button
|
||||
class="density-action" data-tone="primary"
|
||||
v-if="canAssign && assignable"
|
||||
size="small"
|
||||
type="text"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||
@click="openAssign(row)"
|
||||
@@ -774,6 +786,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
type="button"
|
||||
class="container-option"
|
||||
:class="{ selected: item.bookingId === selectedContainerId }"
|
||||
:aria-pressed="item.bookingId === selectedContainerId"
|
||||
@click="selectContainer(item.bookingId)"
|
||||
>
|
||||
<span class="container-option__top">
|
||||
@@ -825,7 +838,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||
</div>
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
@@ -837,28 +850,38 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="assign-dialog">
|
||||
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
||||
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
|
||||
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||
<AppDialog class="admin-density-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">
|
||||
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
|
||||
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
|
||||
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
|
||||
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
|
||||
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<template v-else>
|
||||
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
|
||||
<tr><td colspan="4"><el-form-item label="本次分配箱数" label-position="left" label-width="128px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
|
||||
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
|
||||
<section class="density-dialog-section" data-tone="source"><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></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
||||
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
|
||||
<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>
|
||||
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
|
||||
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
|
||||
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
|
||||
</template>
|
||||
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -867,24 +890,26 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assignment-context-table { table-layout: fixed; }
|
||||
.assignment-context-table th { width: 120px; }
|
||||
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
|
||||
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
|
||||
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
|
||||
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
||||
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
|
||||
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
|
||||
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
|
||||
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: var(--admin-font-body); line-height: 32px; }
|
||||
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; white-space: nowrap; }
|
||||
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
|
||||
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
|
||||
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
|
||||
.compact-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption); }
|
||||
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
|
||||
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
|
||||
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
|
||||
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
|
||||
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
|
||||
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
||||
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
|
||||
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||
@@ -902,9 +927,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
||||
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
|
||||
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
||||
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.container-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
||||
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.container-option__identity { color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
|
||||
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.plan-header { flex-wrap: wrap; }
|
||||
@@ -914,17 +939,15 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
|
||||
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.readonly-status-card strong { font-size: var(--admin-font-title); }
|
||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
|
||||
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { AxiosError } from 'axios'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
@@ -75,7 +76,9 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('150% 容量上限')
|
||||
expect(wrapper.text()).not.toContain('150% 容量上限')
|
||||
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
|
||||
})
|
||||
|
||||
it('shows the full inbound composition for an assigned sealed pallet', async () => {
|
||||
@@ -189,6 +192,11 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
|
||||
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整组加入当前柜次 · CLG-9007199254740999')
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.get('table[aria-label="同柜组成员"]').text()).toContain('ORD-1:IN-201、IN-203')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
|
||||
@@ -222,9 +230,14 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
||||
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
||||
expect(count.props('max')).toBe(12)
|
||||
count.vm.$emit('update:modelValue', 5)
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 5, expectedVersion: planResponse.version })
|
||||
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -245,6 +258,10 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -38,6 +40,12 @@ const noDraft = ref(false)
|
||||
const editingQuantityId = ref('')
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||
const assignmentTitle = computed(() => {
|
||||
const candidate = selectedCandidate.value
|
||||
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'
|
||||
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
})
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||
@@ -307,7 +315,7 @@ async function remove(source: object) {
|
||||
const message = row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
|
||||
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
await ElMessageBox.confirm(message, containerDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', container.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
@@ -386,7 +394,7 @@ onMounted(load)
|
||||
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
@@ -396,7 +404,7 @@ onMounted(load)
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
||||
<template #toolbar-start><div><h2>候选货物</h2></div></template>
|
||||
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
@@ -408,7 +416,35 @@ onMounted(load)
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||
<AppDialog class="admin-density-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">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<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 v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><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></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -444,8 +480,8 @@ onMounted(load)
|
||||
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.assignment-table { table-layout: fixed; }
|
||||
.assignment-table th { width: 90px; }
|
||||
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
|
||||
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 客户模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/customers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,客户与权限语义见 `../../../docs/business-index.md`、`docs/平台客户层级与权限契约.md`。
|
||||
- 客户主档、成员与角色归 `../iam/`,账号与登录标识归 `backend auth/`,资质文件归 `file/`;本模块只做客户视图、审核入口与客户级计费规则。
|
||||
- 后端只查 `iam/`(客户主档与审核)、`auth/`(登录标识)、`file/`(资质)、`firstleg/customerstatistics/`(运营统计)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户列表、筛选与统计 | `pages/CustomersView.vue`;`customers` | `../iam/api/platformIam.ts`;`firstleg/customerstatistics/` |
|
||||
| 企业审核入口 | `pages/CustomersView.vue`;`customers/reviews` 模式 | `iam/` 审核接口;资质 `file/` |
|
||||
| 客户详情、计费规则与运营指标 | `pages/CustomerDetailView.vue`、`components/CustomerBillingRulesPanel.vue`、`components/CustomerOperatingMetricCard.vue`;`customers/:tenantId` | `iam/`、`firstleg/` |
|
||||
| 海外仓阶梯费用 | `pages/OverseasStorageFeesView.vue`、`CustomerOverseasStorageFeePanel.vue`;`customers/overseas-storage-fees` | `overseasStorageFee.ts`、`overseasStorageFeeTiers.ts`;`lastleg-service/` |
|
||||
| 客户箱规与计费主数据 | `components/CustomerBillingRulesPanel.vue` | `firstleg/` 箱规与计价主数据 |
|
||||
| 详情浮层标题与密度 | `customerDialogTitle.ts`、`customerDensity.css` | 本地实现,按公共密度样式约束 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 公司联系电话只作企业联系信息,不修改管理员/成员登录手机号,也不要求跨企业唯一;编辑客户资料不提交账号手机号变更。
|
||||
- 审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换。
|
||||
- 客户列表属 `*:list` 权限,已含详情只读;写操作使用独立权限,前端守卫不替代服务端鉴权。
|
||||
- 平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。
|
||||
- 长整型客户 ID 按字符串透传,不用内部 ID 作业务编号展示。
|
||||
- 海外仓阶梯费用按客户与仓库维度提交版本,金额与生效结果只认服务端返回。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/customers/<目标测试>`;审核必填或费用阶梯变更同时核对 `iam`/`lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -9,6 +9,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from './customerDialogTitle'
|
||||
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
|
||||
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
|
||||
|
||||
@@ -45,10 +46,13 @@ async function openEditor() {
|
||||
form.currency = config.value.currency || 'USD'; form.tiers = cloneTiers(config.value.tiers); form.version = config.value.version
|
||||
await nextTick(); dirty.value = false; visible.value = true; await nextTick(); captureBaseline(); formRef.value?.clearValidate()
|
||||
}
|
||||
async function closeEditor() {
|
||||
async function beforeEditorClose(done: () => void) {
|
||||
if (saving.value) return
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
visible.value = false
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', customerDialogTitle('放弃编辑', { tenantName: props.customerName }), { customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
done()
|
||||
}
|
||||
async function closeEditor() {
|
||||
await beforeEditorClose(() => { visible.value = false })
|
||||
}
|
||||
function validate() {
|
||||
const message = validateFeeTiers(form.tiers)
|
||||
@@ -72,7 +76,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section class="overseas-fee-panel admin-density customer-density">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||
<template v-else-if="config">
|
||||
@@ -80,18 +84,25 @@ onMounted(load)
|
||||
<div class="fee-groups"><div><h3>仓租({{ config.currency }}/CBM/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div><h3>退货仓储费({{ config.currency }}/托/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
|
||||
</template>
|
||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ 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><table><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></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="visible" :title="customerDialogTitle('编辑海外仓费用', { tenantName: customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" 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 v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<div class="customer-fee-tier-groups">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
||||
<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"><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>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.edit-group-heading h3 { min-width:0; }
|
||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups > div { overflow-x:auto; }.edit-groups table { min-width:560px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }
|
||||
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups { grid-template-columns:1fr; padding:12px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElDialog, ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -136,13 +136,23 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
})
|
||||
|
||||
it('submits all four rows in one update request', async () => {
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流', customerCode: 'C0008' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑箱规尺寸 / 计算规则 · C0008')
|
||||
const length = document.querySelector<HTMLInputElement>('.editor-table input[placeholder="请输入内径长"]')!
|
||||
length.value = '31'
|
||||
length.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
length.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-density-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(length.value).toBe('31.000')
|
||||
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')
|
||||
saveButton?.click(); await flushPromises()
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', innerLengthCm: 31, version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
|
||||
const submittedRules = vi.mocked(updateCustomerDimensionConfig).mock.calls[0][1].rules
|
||||
expect(submittedRules).toHaveLength(4)
|
||||
expect(Object.keys(submittedRules[0]).sort()).toEqual([
|
||||
@@ -190,4 +200,43 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
await flushPromises()
|
||||
expect(updateCustomerDimensionDefaultCalculationMode).toHaveBeenCalledWith('8', 'MULTIPLY')
|
||||
})
|
||||
it('keeps dimension changes when header abandonment is declined', async () => {
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流', customerCode: 'C0008' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '编辑规则')!.click()
|
||||
await flushPromises()
|
||||
const input = document.querySelector<HTMLInputElement>('.editor-table input[placeholder="请输入内径长"]')!
|
||||
input.value = '32'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
document.querySelector<HTMLButtonElement>('.customer-density-dialog .el-dialog__headerbtn')!.click()
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('尚未保存'), '放弃编辑 · C0008', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(input.value).toBe('32.000')
|
||||
confirmation.mockRestore()
|
||||
})
|
||||
|
||||
it('locks header and footer closing while coefficient rules are saving', async () => {
|
||||
let finish!: (rule: CustomerChargeRule) => void
|
||||
vi.mocked(createCustomerChargeRule).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '编辑规则')!.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '保存系数规则')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(wrapper.findComponent(ElDialog).props('closeOnPressEscape')).toBe(false)
|
||||
const done = vi.fn()
|
||||
await wrapper.findComponent(AppDialog).props('beforeClose')!(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
finish(chargeRule)
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
|
||||
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
|
||||
@@ -196,7 +198,7 @@ async function openEditor() {
|
||||
|
||||
async function confirmAbandon(): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', '放弃编辑', {
|
||||
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', customerDialogTitle('放弃编辑', { tenantCode: props.customerCode, tenantName: props.customerName }), {
|
||||
customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
})
|
||||
return true
|
||||
@@ -206,12 +208,12 @@ async function confirmAbandon(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function beforeEditorClose(done: () => void) {
|
||||
if (editorSaving.value) return
|
||||
if (editorSaving.value || chargeSaving.value) return
|
||||
if (!editorDirty.value || await confirmAbandon()) done()
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
if (editorSaving.value) return
|
||||
if (editorSaving.value || chargeSaving.value) return
|
||||
if (editorDirty.value && !await confirmAbandon()) return
|
||||
editorVisible.value = false
|
||||
}
|
||||
@@ -397,7 +399,7 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
|
||||
|
||||
<section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card">
|
||||
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div><p class="section-hint">勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></div></header>
|
||||
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算<InlineHelp title="默认规则说明"><p>勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></InlineHelp></h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div></div></header>
|
||||
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
|
||||
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
|
||||
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
|
||||
@@ -410,20 +412,21 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<div class="editor-content">
|
||||
<p class="editor-formula">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<el-form ref="editorForm" :model="form" label-position="top">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" :title="customerDialogTitle('编辑箱规尺寸 / 计算规则', { tenantCode: customerCode, tenantName: customerName })" fullscreenable width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving || chargeSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<el-form ref="editorForm" :model="form" label-position="top" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">按箱规尺寸计算<InlineHelp title="箱规计算公式"><p>计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p></InlineHelp></h3>
|
||||
|
||||
<div class="editor-table-wrap">
|
||||
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<table class="density-table density-table--editable editor-table" aria-label="箱规尺寸规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" placeholder="请输入边长加值" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
<div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
</section>
|
||||
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算<InlineHelp title="系数匹配规则"><p>长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p></InlineHelp></h3><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -433,23 +436,23 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
.rule-heading { align-items: center; }
|
||||
.rule-heading h2 { margin: 0; color: var(--cc-text); }.rule-heading p { margin: var(--admin-space-2, 6px) 0 0; color: var(--cc-muted); }
|
||||
.heading-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||
.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }.heading-actions :deep(.el-button) { margin-left: 0; }.dialog-actions :deep(.el-button) { margin-left: 0; }
|
||||
.heading-actions :deep(.el-button) { margin-left: 0; }
|
||||
.rule-card { min-width: 0; }.rule-card-heading { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); }.formula-band { display: flex; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--cc-border); background: var(--admin-label-bg, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-body, 13px); }.formula-band strong { color: #344054; }
|
||||
.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-radius: 6px; background: var(--admin-surface, #fff); }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); }
|
||||
.rule-state { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: var(--admin-space-3, 8px); color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: var(--admin-font-body, 13px); }.rule-state p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.rule-error { display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: var(--admin-font-caption, 12px); }
|
||||
.volume-value { color: #1570ef; }.editor-content { display: grid; }.editor-formula { margin: 0; color: #475467; font-size: var(--admin-font-body, 13px); line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.editor-table tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.editor-table :deep(.el-input-number) { width: 140px; }.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number) { width: 100%; }.charge-form :deep(.el-input) { width: 100%; }
|
||||
.charge-form--simple { grid-template-columns: 1fr; }.charge-form--simple :deep(.el-form-item) { grid-column: 1; }
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.charge-rule-editor tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.charge-rule-editor :deep(.el-input-number) { width: 118px; }.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; }
|
||||
.charge-box-cell { min-height: 34px; display: flex; align-items: center; border-bottom: 1px solid var(--cc-border); }.charge-box-cell:last-child { border-bottom: 0; }
|
||||
.rule-save-row { display: flex; justify-content: flex-end; padding: var(--admin-space-5, 12px) 0 var(--admin-space-1, 4px); }
|
||||
.dialog-actions { width: 100%; }
|
||||
.dialog-actions :deep(.el-button) { min-width: 112px; min-height: 46px; font-size: 15px; }
|
||||
.volume-value { color: #1570ef; } .editor-table,.charge-rule-editor { min-width:920px; table-layout:fixed; } .editor-table th:first-child,.charge-rule-editor th:first-child { width:100px; } .editor-table th:last-child,.charge-rule-editor th:last-child { width:20%; }.editor-table-wrap { overflow-x: auto; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
|
||||
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }
|
||||
|
||||
.rule-save-row { padding: var(--admin-space-5, 12px) 0 var(--admin-space-1, 4px); }
|
||||
|
||||
|
||||
.charge-rule-card :deep(.el-table) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table th.el-table__cell) { background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-body, 13px); font-weight: 650; }
|
||||
.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table .cell) { line-height: 1.5; text-align: center; }
|
||||
.charge-rule-card :deep(.el-table .charge-remark-column .cell) { text-align: left; }
|
||||
.charge-rule-card :deep(.el-table strong) { color: #1570ef; font-weight: 650; }
|
||||
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
|
||||
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -41,21 +41,7 @@
|
||||
.customer-density-dialog.admin-density-dialog .file-picker { min-height: var(--admin-control-height, 32px); }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-file-name { max-width: none; overflow-wrap: anywhere; white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-preview-action { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-header { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; background: var(--admin-surface, #fff); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-title strong { white-space: normal; overflow-wrap: anywhere; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-icon { display: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-grid { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-section,.customer-density-dialog.admin-density-dialog .review-decision-card { padding: var(--admin-space-4, 10px); border-radius: 0; box-shadow: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading span { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-info-grid { grid-template-columns: 1fr; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-table { border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-header { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-name { white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .decision-summary { margin-bottom: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials { margin-top: var(--admin-space-5, 12px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials > div { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); }
|
||||
|
||||
.customer-density.admin-density .rule-heading,.customer-density.admin-density .rule-card-heading,.customer-density.admin-density .panel-heading { min-height: 0; padding: var(--admin-space-3, 8px) 0; gap: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .rule-heading h2,.customer-density.admin-density .rule-card-heading h2,.customer-density.admin-density .panel-heading h2 { font-size: var(--admin-font-section, 14px); }
|
||||
@@ -66,18 +52,34 @@
|
||||
.customer-density.admin-density .charge-rule-card { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .overseas-fee-panel { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .fee-groups { gap: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) 0; }
|
||||
.customer-density.admin-density .fee-groups h3,.customer-density-dialog.admin-density-dialog .edit-groups h3,.customer-density-dialog.admin-density-dialog .groups h3 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .fee-groups h3 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .fee-groups th,.customer-density.admin-density .fee-groups td { padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-formula { padding: var(--admin-space-2, 6px) 0; }
|
||||
.customer-density-dialog.admin-density-dialog .editor-table th,.customer-density-dialog.admin-density-dialog .editor-table td,
|
||||
.customer-density-dialog.admin-density-dialog .charge-rule-editor th,.customer-density-dialog.admin-density-dialog .charge-rule-editor td,
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups th,.customer-density-dialog.admin-density-dialog .edit-groups td { padding: var(--admin-space-1, 4px) var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions { justify-content: flex-end; }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions .el-button { min-height: var(--admin-control-height, 32px); min-width: 64px; font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups,.customer-density-dialog.admin-density-dialog .groups { gap: var(--admin-space-5, 12px); padding: 0; }
|
||||
|
||||
@media (max-width: 960px) { .customer-density.admin-density .info-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width: 720px) { .customer-density.admin-density .info-grid { grid-template-columns: 1fr; }.customer-density.admin-density .qualification-list button { grid-template-columns: minmax(0,1fr) auto; }.customer-density.admin-density .qualification-list .file-date { white-space: normal; } }
|
||||
|
||||
.customer-density-dialog .review-info-grid { --admin-field-label-width: 96px; }
|
||||
|
||||
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
||||
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
||||
.customer-editor-dialog .customer-editor-table td { width: 50%; }
|
||||
.customer-review-table th { width: 120px; }
|
||||
.customer-review-documents th:first-child { width: 150px; }
|
||||
.customer-review-documents th:last-child { width: 150px; }
|
||||
.customer-credential-value { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
.customer-credential-value code, .customer-preview-filename { overflow-wrap: anywhere; }
|
||||
.customer-qualification-preview .qualification-preview { display: grid; min-height: 240px; place-items: center; }
|
||||
.customer-qualification-preview .qualification-preview img { display: block; max-width: 100%; height: auto; }
|
||||
.customer-qualification-preview .qualification-preview iframe { width: 100%; min-height: min(68vh, 720px); border: 0; }
|
||||
.customer-qualification-preview.is-fullscreen .qualification-preview iframe { height: calc(100vh - 200px); min-height: 0; }
|
||||
|
||||
/* Both overseas fee entry points share geometry; pricing values and actions stay in their own controllers. */
|
||||
.customer-density-dialog .customer-fee-tier-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
|
||||
.customer-density-dialog .customer-fee-tier-groups > section { min-width:0; }
|
||||
.customer-density-dialog .customer-fee-tier-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--admin-space-3); margin-bottom:var(--admin-space-3); }
|
||||
.customer-density-dialog .customer-fee-tier-heading h3 { margin-bottom:0; }
|
||||
.customer-density-dialog .customer-fee-tier-table-wrap { overflow-x:auto; }
|
||||
.customer-density-dialog .customer-fee-tier-table { min-width:420px; table-layout:fixed; }
|
||||
.customer-density-dialog .customer-fee-tier-table th:first-child { width:70px; }
|
||||
.customer-density-dialog .customer-fee-tier-table :is(th, td):first-child { position:sticky; left:0; z-index:1; }
|
||||
.customer-density-dialog .customer-fee-currency .el-select { max-width:180px; }
|
||||
@media (max-width:1000px) { .customer-density-dialog .customer-fee-tier-groups { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { customerDialogTitle } from './customerDialogTitle'
|
||||
|
||||
describe('customer operation titles', () => {
|
||||
it('identifies the customer by its trimmed business code', () => {
|
||||
expect(customerDialogTitle('编辑客户', { tenantCode: ' C0008 ', tenantName: '客户公司' })).toBe('编辑客户 · C0008')
|
||||
})
|
||||
it('uses the company name when a business code is unavailable', () => {
|
||||
expect(customerDialogTitle('客户入驻审核', { tenantCode: ' ', tenantName: ' 客户公司 ' })).toBe('客户入驻审核 · 客户公司')
|
||||
})
|
||||
it('does not expose database IDs when both business identifiers are absent', () => {
|
||||
const customer = { id: '987654321', tenantCode: null, tenantName: ' ' }
|
||||
expect(customerDialogTitle('删除客户', customer)).toBe('删除客户')
|
||||
expect(customerDialogTitle('新增客户')).toBe('新增客户')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
interface CustomerDialogIdentity {
|
||||
tenantCode?: string | null
|
||||
tenantName?: string | null
|
||||
}
|
||||
|
||||
export function customerDialogTitle(action: string, customer?: CustomerDialogIdentity | null): string {
|
||||
const identity = customer?.tenantCode?.trim() || customer?.tenantName?.trim()
|
||||
return identity ? `${action} · ${identity}` : action
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import ElementPlus, { ElDialog, ElMessageBox, ElOption } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CustomerOverseasStorageFeePanel from './CustomerOverseasStorageFeePanel.vue'
|
||||
import OverseasStorageFeesView from './pages/OverseasStorageFeesView.vue'
|
||||
import { getCustomerOverseasStorageFees, listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig } from './overseasStorageFee'
|
||||
|
||||
vi.mock('./overseasStorageFee', () => ({
|
||||
getCustomerOverseasStorageFees: vi.fn(),
|
||||
listOverseasStorageFees: vi.fn(),
|
||||
updateCustomerOverseasStorageFees: vi.fn(),
|
||||
}))
|
||||
|
||||
const customerId = '9007199254740993'
|
||||
function configuration(): OverseasStorageFeeConfig {
|
||||
return { id: 'fee-1', customerId, customerName: '费用测试客户', currency: 'USD', version: 7, createdAt: null, updatedAt: null, tiers: [
|
||||
{ id: 'storage', feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0.123456, sortNo: 1 },
|
||||
{ id: 'return', feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: null, unitPrice: 2, sortNo: 1 },
|
||||
] }
|
||||
}
|
||||
function button(text: string) {
|
||||
const found = [...document.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)
|
||||
expect(found, text).toBeDefined()
|
||||
return found!
|
||||
}
|
||||
function changeNumber(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
async function open(entry: 'detail' | 'list') {
|
||||
const wrapper = entry === 'detail'
|
||||
? mount(CustomerOverseasStorageFeePanel, { attachTo: document.body, props: { customerId, customerName: '费用测试客户' }, global: { plugins: [ElementPlus] } })
|
||||
: mount(OverseasStorageFeesView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
if (entry === 'detail') button('编辑费用').click()
|
||||
else {
|
||||
const action = document.querySelector<HTMLButtonElement>('.el-table__body .row-actions button')
|
||||
expect(action).not.toBeNull()
|
||||
action!.click()
|
||||
}
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { vi.restoreAllMocks(); ElMessageBox.close() })
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'test-session'
|
||||
auth.permissions = ['platform:customer:list', 'platform:customer:edit', 'platform:overseas-storage-fee:edit']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getCustomerOverseasStorageFees).mockImplementation(async () => configuration())
|
||||
vi.mocked(listOverseasStorageFees).mockImplementation(async () => ({ records: [configuration()], total: 1, page: 1, size: 20 }))
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockImplementation(async () => configuration())
|
||||
})
|
||||
|
||||
describe('customer overseas fee dialog entry points', () => {
|
||||
it.each(['detail', 'list'] as const)('retains %s currency choices, values, version and full customer ID on save', async entry => {
|
||||
const wrapper = await open(entry)
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe(`${entry === 'detail' ? '编辑海外仓费用' : '编辑海外仓阶梯费用'} · 费用测试客户`)
|
||||
expect(dialog.props('fullscreenable')).toBe(true)
|
||||
expect(dialog.findAllComponents(ElOption).map(option => option.props('value'))).toEqual(entry === 'detail' ? ['USD', 'CNY', 'HKD', 'EUR', 'GBP'] : ['USD', 'CNY', 'EUR'])
|
||||
const prices = document.querySelectorAll<HTMLInputElement>('.customer-fee-tier-table input[placeholder^="请输入"]')
|
||||
expect(prices).toHaveLength(4)
|
||||
const price = document.querySelector<HTMLInputElement>(entry === 'detail' ? 'input[placeholder="请输入价格"]' : 'input[placeholder="请输入单价"]')!
|
||||
changeNumber(price, '0.654321')
|
||||
await flushPromises()
|
||||
button('新增阶梯').click()
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.customer-fee-tier-table tbody tr')).toHaveLength(3)
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-density-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(price.value).toBe('0.654321')
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(updateCustomerOverseasStorageFees).toHaveBeenCalledWith(customerId, {
|
||||
customerId, customerName: '费用测试客户', currency: 'USD', version: 7, tiers: [
|
||||
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: 1, unitPrice: 0.654321 },
|
||||
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 2, endDay: null, unitPrice: 0.654321 },
|
||||
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: null, unitPrice: 2 },
|
||||
],
|
||||
})
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('applies dirty confirmation to the detail header close and preserves inputs when declined', async () => {
|
||||
const wrapper = await open('detail')
|
||||
const price = document.querySelector<HTMLInputElement>('input[placeholder="请输入价格"]')!
|
||||
changeNumber(price, '9.123456')
|
||||
await flushPromises()
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
document.querySelector<HTMLButtonElement>('.customer-density-dialog .el-dialog__headerbtn')!.click()
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('尚未保存'), '放弃编辑 · 费用测试客户', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(price.value).toBe('9.123456')
|
||||
const done = vi.fn()
|
||||
const closing = wrapper.findComponent(AppDialog).props('beforeClose')!(done)
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('.el-message-box .el-button--primary')).at(-1)!.click()
|
||||
await closing
|
||||
expect(done).toHaveBeenCalledOnce()
|
||||
button('取消').click()
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('.el-message-box .el-button--primary')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(updateCustomerOverseasStorageFees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['detail', 'list'] as const)('keeps %s inputs and context when saving fails', async entry => {
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockRejectedValueOnce(new Error('费用版本冲突'))
|
||||
const wrapper = await open(entry)
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.body.textContent).toContain(entry === 'detail' ? '海外仓费用保存失败,请刷新后重试' : '保存失败,请刷新后重试')
|
||||
expect(document.querySelectorAll('.customer-fee-tier-table tbody tr')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each(['detail', 'list'] as const)('locks %s close actions during save', async entry => {
|
||||
let finish!: (value: OverseasStorageFeeConfig) => void
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = await open(entry)
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(wrapper.findComponent(ElDialog).props('closeOnPressEscape')).toBe(false)
|
||||
finish(configuration())
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,10 @@ describe('customer overseas storage fee api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps overseas fee business actions as semantic buttons at narrow widths', () => {
|
||||
expect(overseasListSource).toContain('border stripe')
|
||||
expect(overseasListSource).toContain('density-table density-table--editable customer-fee-tier-table')
|
||||
expect(overseasListSource).toContain('class="density-action" size="small" plain')
|
||||
expect(overseasListSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasListSource).toContain('<el-table :data="group" size="small" border stripe>')
|
||||
expect(overseasListSource).toContain('<thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead>')
|
||||
expect(overseasListSource).not.toContain('<Pencil')
|
||||
expect(overseasListSource).not.toContain('<Trash2')
|
||||
expect(overseasListSource).toContain('@media (max-width:720px)')
|
||||
|
||||
@@ -99,7 +99,9 @@ describe('CustomerDetailView', () => {
|
||||
expect(document.body.textContent).toContain('企业管理员')
|
||||
expect(document.body.textContent).toContain('公司联系电话')
|
||||
expect(document.body.textContent).toContain('登录手机号')
|
||||
expect(document.body.textContent).toContain('修改会影响登录')
|
||||
expect(document.querySelector('.user-panel')?.textContent).not.toContain('修改会影响登录')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="查看账号信息说明"]')!.focus()
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('修改会影响登录'))
|
||||
expect(document.querySelector('.contact-panel .info-grid--three')).not.toBeNull()
|
||||
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
|
||||
expect(document.body.textContent).toContain('已替换')
|
||||
@@ -121,6 +123,11 @@ describe('CustomerDetailView', () => {
|
||||
|
||||
expect(downloadTenantQualification).toHaveBeenCalledWith(tenant.id, '/files/license.png')
|
||||
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
|
||||
expect(document.querySelector('.customer-qualification-preview .el-dialog__title')?.textContent).toBe('查看资质文件 · TEN8')
|
||||
expect(document.querySelector('.customer-preview-filename')?.textContent).toBe('license.png')
|
||||
document.querySelector<HTMLButtonElement>('.customer-qualification-preview button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-qualification-preview.is-fullscreen img')?.getAttribute('src')).toBe('blob:qualification-preview')
|
||||
})
|
||||
|
||||
it('reloads a reused detail view and ignores the previous customer response', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -21,6 +22,7 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
@@ -163,7 +165,7 @@ async function changeStatus() {
|
||||
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, customer.value), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||||
})
|
||||
statusChanging.value = true
|
||||
@@ -252,7 +254,7 @@ onBeforeUnmount(() => {
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<div class="basic-layout">
|
||||
<section class="detail-panel company-panel">
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2></div><Building2 :size="19" /></div>
|
||||
<dl class="info-grid density-fields">
|
||||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||||
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
|
||||
@@ -274,7 +276,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
|
||||
</section>
|
||||
<section class="detail-panel contact-panel">
|
||||
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>联系与合作信息</h2></div><UserRound :size="19" /></div>
|
||||
<dl class="info-grid info-grid--three density-fields">
|
||||
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
|
||||
@@ -284,7 +286,7 @@ onBeforeUnmount(() => {
|
||||
</dl>
|
||||
</section>
|
||||
<section class="detail-panel user-panel">
|
||||
<div class="panel-title"><div><h2>企业用户</h2><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></div><UsersRound :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div>
|
||||
<div v-if="enterpriseMembers.length" class="user-table-wrap">
|
||||
<table><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead>
|
||||
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
|
||||
@@ -307,8 +309,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview" v-model="previewOpen" :title="customerDialogTitle('查看资质文件', customer)" fullscreenable width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<p class="density-dialog-note customer-preview-filename">{{ previewName }}</p>
|
||||
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -345,7 +348,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:var(--a
|
||||
.member-contact span { display:block; }
|
||||
.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:var(--admin-font-caption, 12px); }
|
||||
.contract-note { padding:var(--admin-space-6, 16px) 18px; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.contract-note strong { color:#344054; font-size:var(--admin-font-caption, 12px); }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
|
||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:var(--admin-surface, #fff); }
|
||||
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
|
||||
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user