Compare commits

...
59 Commits
Author SHA1 Message Date
ouyangxq ba31dd0563 优化 2026-09-14 12:00:54 +08:00
ouyangxq 1da976ecb6 Merge origin/dev into release/test preserving current workflows 2026-09-14 11:05:47 +08:00
ouyangxq bd12b0a594 1 2026-09-14 10:27:47 +08:00
ouyangxq 89dc3bf671 优化 2026-09-14 10:02:39 +08:00
ouyangxq 8bc86c8567 优化 2026-09-12 11:45:57 +08:00
ouyangxq 497530d6a9 优化 2026-09-11 18:44:01 +08:00
ouyangxq b9330a9674 优化 2026-09-11 17:27:30 +08:00
ouyangxq ce399e4153 优化 2026-09-11 17:20:58 +08:00
ouyangxq 2334bd846a 优化 2026-09-11 17:01:59 +08:00
ouyangxq 737dd41ff1 优化 2026-09-11 16:17:21 +08:00
ouyangxq 80a4b3ab5e fix: clarify automatic pallet supplementation 2026-09-11 16:14:14 +08:00
ouyangxq 3ea1e279c6 fix: show cross-plan merged pallet ownership 2026-09-11 16:11:31 +08:00
ouyangxq 6d26634b1b feat: clarify overseas pallet supplementation 2026-09-11 15:39:47 +08:00
ouyangxq b673e22cb7 fix: validate dropship pallet estimate against cartons 2026-09-11 12:26:45 +08:00
ouyangxq e0b1334d2d feat: add expected pallet count for dropship receiving 2026-09-11 12:12:41 +08:00
ouyangxq 385c5d9e9e 优化 2026-09-11 11:51:33 +08:00
ouyangxq 6b7536b95c 优化 2026-09-11 11:19:45 +08:00
ouyangxq 23e991722c 优化 2026-09-11 11:15:04 +08:00
ouyangxq f702a819fa fix: complete overseas receiving detail facts 2026-09-11 10:32:29 +08:00
ouyangxq d92a883f8a fix: open dispatch BOL as PDF 2026-09-11 10:09:14 +08:00
ouyangxq b7b9835bf9 fix: wait for customer dimension initialization 2026-09-11 02:16:51 +08:00
ouyangxq 2817ca1d3f fix: isolate BOL print layout 2026-09-10 23:30:16 +08:00
ouyangxq 950967f101 feat: align finance account and overseas actions 2026-09-10 22:43:10 +08:00
ouyangxq b6b91297ae fix(customers): wait for billing defaults before loading 2026-09-10 22:12:27 +08:00
ouyangxq 781d25de08 feat: add dispatch BOL printing 2026-09-10 21:22:06 +08:00
ouyangxq 14907551d7 优化 2026-09-10 20:38:06 +08:00
ouyangxq 219fd3009f 优化 2026-09-10 20:31:17 +08:00
ouyangxq 5a165add2c Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-10 20:26:57 +08:00
ouyangxq b57ea2d628 feat(admin): use smart-loading groups for simulations 2026-09-10 19:25:11 +08:00
ouyangxq 27c4ec759c 优化 2026-09-10 17:13:08 +08:00
ouyangxq 5f1a7f04f1 fix: clarify automatic pallet generation 2026-09-10 17:05:48 +08:00
ouyangxq 0ed913a114 fix: expose manual plans awaiting pallets 2026-09-10 16:33:43 +08:00
ouyangxq 4382351569 fix: show pallets across warehouses by default 2026-09-10 16:03:01 +08:00
ouyangxq dc1cbfcf4a fix: align inbound pallet plans with preview 2026-09-10 15:40:55 +08:00
ouyangxq a2f1ecc6b9 fix(admin): default manual node times to now 2026-09-10 15:23:39 +08:00
ouyangxq 568f2dea8a fix(admin): remove duplicate loading list heading 2026-09-10 14:41:47 +08:00
ouyangxq 84a194db80 优化 2026-09-10 14:17:08 +08:00
ouyangxq c3d69d74c2 优化 2026-09-10 14:16:20 +08:00
ouyangxq 33b79edd18 优化 2026-09-10 14:15:44 +08:00
ouyangxq 2460278cd9 优化 2026-09-10 14:14:24 +08:00
ouyangxq 60734cbb03 fix(admin): show empty customs state for unloaded plan 2026-09-10 14:02:37 +08:00
ouyangxq 10fa397013 优化 2026-09-10 12:18:39 +08:00
ouyangxq 20e4da5bb5 fix(admin): allow manual container nodes without tracking 2026-09-10 12:17:54 +08:00
ouyangxq e3b2552d8d 优化 2026-09-10 11:55:58 +08:00
ouyangxq ae7d8fb22e 优化 2026-09-10 11:52:30 +08:00
ouyangxq 911c6acad2 test(admin): cover outer dimension item update 2026-09-10 11:42:14 +08:00
ouyangxq 8c9f4468af 优化 2026-09-10 11:38:38 +08:00
ouyangxq 9b5b324fe8 优化 2026-09-10 11:17:45 +08:00
ouyangxq 6a038c89ca fix(admin): update submitted measurement calculation strategy 2026-09-10 11:02:52 +08:00
ouyangxq fc0226d4cb 优化 2026-09-10 10:22:48 +08:00
ouyangxq 503a4bac89 优化 2026-09-10 10:10:04 +08:00
ouyangxq 57c987da88 优化 2026-09-09 20:53:30 +08:00
ouyangxq 8b862da084 fix(admin): lock submitted measurement rule editing 2026-09-09 20:36:49 +08:00
ouyangxq f5a43c1cfe test: verify pallet label internal code dialog 2026-09-09 18:35:35 +08:00
ouyangxq 114def1c11 优化 2026-09-09 18:29:47 +08:00
ouyangxq 108ad5c7b5 feat: show FBA internal code on pallet labels 2026-09-09 18:20:05 +08:00
ouyangxq ae01e066ca 优化 2026-09-09 17:58:23 +08:00
ouyangxq 2fd9c5fcc7 优化 2026-09-09 17:42:19 +08:00
ouyangxq a47a7e9767 feat: add pallet label printing 2026-09-09 17:27:00 +08:00
125 changed files with 3343 additions and 1762 deletions
+10 -13
View File
@@ -33,6 +33,8 @@
## 模块快速路由
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
@@ -49,9 +51,9 @@
| 入库扣货、解除与凭证历史 | `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/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/carrier-rates/`、`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `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`,承载模拟柜、订舱关联、智能装柜、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `file/` |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `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`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `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/` |
@@ -63,28 +65,23 @@
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | API 为 `dispatches.ts`、`outbounds.ts`、`deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | API 为 `exceptionOrders.ts`、`customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API |
| 海外排车、出库与派送 | `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 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
## 海外模块内部快速索引(海外仓全流程)
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询与修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;是否展示生成动作只消费 `allPlansReady/pendingPlanCount`,不得在浏览器拼柜或自行推导就绪。确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示托数、`readiness/blockers`,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`;前端不计算托数、不逐票建计划、不预占托盘,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划并编排真实托盘批量预留/确认,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、智能预排与转派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`OutboundDetailView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `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/` |
- 三个海外模块统一使用 `shared/api/overseasHttp.ts`;页面是否已接真实接口先查对应 `api/` 和挂载测试,不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替当前代码与服务端契约。
## 业务、安全与共享装配
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
+2 -1
View File
@@ -60,7 +60,8 @@ describe('AdminSidebarMenu', () => {
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
expect(groupTitles).toContain('客户管理')
expect(groupTitles).toContain('供应商中心')
expect(groupTitles).not.toContain('供应商中心')
expect(groupTitles).toContain('通用')
expect(groupTitles).toContain('系统管理')
expect(groupTitles).not.toContain('财务管理')
expect(paths).toContain('/dashboard')
+5 -7
View File
@@ -14,7 +14,6 @@ describe('adminNavigation', () => {
'客户管理',
'国内仓',
'海外派送',
'供应商中心',
'货柜管理',
'海外业务',
'海外仓',
@@ -38,11 +37,7 @@ describe('adminNavigation', () => {
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
'供应商列表',
'快递公司',
'往来单位',
])
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'订舱列表',
'拖车列表',
@@ -63,6 +58,9 @@ describe('adminNavigation', () => {
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'供应商列表',
'快递公司',
'往来单位',
'费用项目',
'汇率管理',
'仓库管理',
@@ -211,7 +209,7 @@ describe('adminNavigation', () => {
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用'])
expect(paths).toEqual([
'/dashboard',
'/orders',
+3 -11
View File
@@ -25,7 +25,6 @@ import {
PrototypeOverseasBusinessIcon,
PrototypeOverseasWarehouseIcon,
PrototypeSalesIcon,
PrototypeSupplierIcon,
PrototypeSystemIcon,
} from './prototypeMenuIcons'
@@ -85,16 +84,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
],
},
{
key: 'supplier-center',
title: '供应商中心',
icon: PrototypeSupplierIcon,
children: [
{ 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' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
],
},
{
key: 'container-management',
title: '货柜管理',
@@ -171,6 +160,9 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeGeneralIcon,
children: [
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', 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' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
@@ -4,7 +4,7 @@ import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, dow
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
startedAt: null, completedAt: null,
@@ -16,12 +16,20 @@ describe('platform container customs API', () => {
it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs })
const result = await getContainerCustoms('2092438843630391402')
expect(result).not.toBeNull()
if (!result) throw new Error('expected completed loading plan customs view')
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402')
expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' })
expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' })
expect(result.documents[0]?.documentId).toBe('2092438843630391407')
})
it('returns null while the loading plan is not completed', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: null })
await expect(getContainerCustoms('2097922185010147329')).resolves.toBeNull()
})
it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
await startContainerCustoms('2092438843630391402', 3)
@@ -41,6 +41,7 @@ export interface ContainerCustomsView {
cabinetNo: string
containerNo: string | null
status: ContainerCustomsStatus
hasActualCargo: boolean
version: number
taxRefundCount: number
coveredTaxRefundCount: number
@@ -74,8 +75,9 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
}
export async function getContainerCustoms(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
return normalize(unwrapApiResponse(response))
const response = await http.get<ApiEnvelope<RawContainerCustomsView | null>, ApiEnvelope<RawContainerCustomsView | null>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
const value = unwrapApiResponse(response)
return value ? normalize(value) : null
}
export function downloadContainerCustomsDocument(documentId: string) {
@@ -17,7 +17,7 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: 9007199254740999, unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelGroupCode: 'EXX', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: 9007199254740999, unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const plan = {
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
@@ -84,10 +84,10 @@ describe('platform container loading API', () => {
it('uses the frozen simulation create and ocean booking link contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
await createContainerSimulation({ channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
await unlinkContainerOceanBooking('7', 4, '空柜改配')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
})
@@ -28,7 +28,7 @@ import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
@@ -78,6 +78,32 @@ describe('ContainerCustomsPanel', () => {
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => {
api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, inbounds: [], documents: [] })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('当前装柜计划没有实际装柜货物,无需报关')
expect(wrapper.text()).toContain('无需报关')
expect(buttonTexts(wrapper)).not.toContain('开始报关')
expect(buttonTexts(wrapper)).not.toContain('导出买单 XLSX')
})
it('shows a pending state instead of an error before loading is completed', async () => {
api.get.mockResolvedValue(null)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2097922185010147329', canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('当前装柜计划尚未完成,完成装柜后可处理报关')
expect(wrapper.text()).not.toContain('整柜报关加载失败')
})
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('上传报关完成文件')
@@ -37,7 +37,7 @@ const completionFiles = reactive<Record<string, File | null>>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.taxRefundCount === 0)
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
@@ -156,9 +156,9 @@ onMounted(load)
<header class="customs-header">
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ statusText[customs.status] }}</el-tag>
<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>
<el-button v-if="canManage && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
@@ -166,6 +166,8 @@ onMounted(load)
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
@@ -182,8 +184,10 @@ onMounted(load)
</tr>
</tbody></table>
</div>
</template>
<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" />
</section>
</template>
@@ -47,6 +47,14 @@ describe('ContainerOverviewPanel', () => {
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('shows the channel restriction and suppresses DO uploads when the document is not applicable', () => {
const withoutDo = { ...overview, documents: overview.documents.filter((item) => item.documentType !== 'DELIVERY_ORDER') }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutDo, canManageDocuments: true, canManageCustoms: true, canManageException: true, deliveryOrderBlockedReason: '海铁渠道不适用 DO 资料' }, global: { plugins: [ElementPlus] } })
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
expect(deliveryRow.text()).toContain('海铁渠道不适用 DO 资料')
expect(deliveryRow.find('button').exists()).toBe(false)
})
it('renders two file rows and one inline PU code row without a nested heading', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
@@ -13,6 +13,7 @@ const props = defineProps<{
canManageException: boolean
documentBusy?: boolean
pickupCodeBusy?: boolean
deliveryOrderBlockedReason?: string | null
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
@@ -38,6 +39,7 @@ function canDownloadDocument(documentType: string) {
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
}
function canUploadDocument(documentType: string) {
if (documentType === 'DELIVERY_ORDER' && props.deliveryOrderBlockedReason) return false
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
}
function hasDocumentAction(documentType: string) {
@@ -130,7 +132,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td>
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
@@ -165,7 +167,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -11,6 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { createContainerSimulation } from '../api/platformContainerLoading'
import type { ContainerSimulationCreateRequest } from '../types'
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
const props = withDefaults(defineProps<{
loadWarehouseOptions?: () => Promise<PlatformWarehouseSummaryResource[]>
@@ -27,15 +28,19 @@ const simulationError = ref<ApiErrorDetails | null>(null)
const simulationChannels = ref<OceanLogisticsChannel[]>([])
const simulationChannelsLoading = ref(false)
const simulationFormRef = ref<FormInstance>()
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
const simulationChannelGroups = computed(() => {
const activeCodes = new Set(simulationChannels.value.map((channel) => channel.channelCode))
return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code)))
})
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
channelGroupCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
async function openSimulation() {
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
Object.assign(simulationForm, { channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
@@ -59,7 +64,7 @@ async function selectSimulationWarehouse(value: unknown) {
const sequence = ++channelSequence
simulationChannelsLoading.value = false
simulationForm.warehouseId = String(value || '')
simulationForm.channelCode = ''
simulationForm.channelGroupCode = ''
simulationChannels.value = []
simulationError.value = null
if (!simulationForm.warehouseId) return
@@ -92,7 +97,7 @@ async function 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="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></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)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></el-form-item>
</el-form>
@@ -0,0 +1,60 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import type { ContainerBookingResource } from '../types'
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
const container = {
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null,
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null,
loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 7,
} satisfies ContainerBookingResource
const candidate = {
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HC', capacityCbm: 76,
maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
}
describe('OceanBookingLinkDialog', () => {
beforeEach(() => {
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [candidate], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(linkContainerOceanBooking).mockReset().mockResolvedValue({ ...container, oceanBookingId: '101', oceanBookingRequirementId: '202', version: 8 } as never)
})
it('loads matching released-booking requirements when opened', async () => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 })
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HC × 1')
wrapper.unmount()
})
it.each([
[' mscu1234567 ', 'MSCU1234567'],
['', null],
])('normalizes and submits the optional real container number %#', async (input, expected) => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
vm.candidateKey = '101:202'
vm.containerNo = input
await vm.save()
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
expect(wrapper.emitted('linked')).toHaveLength(1)
wrapper.unmount()
})
})
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
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 type { ContainerBookingResource } from '../types'
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
linked: [container: ContainerBookingResource]
}>()
const loading = ref(false)
const saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const candidateKey = ref('')
const containerNo = ref('')
const candidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string }>>([])
let requestSequence = 0
function optional(value: string | null | undefined) { return value?.trim() || null }
function normalizeContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
function close() { emit('update:modelValue', false) }
async function loadCandidates() {
const target = props.container
candidateKey.value = ''
containerNo.value = ''
candidates.value = []
error.value = null
if (!target) return
const warehouseId = optional(target.warehouseId)
const channelGroupCode = optional(target.channelGroupCode)
const containerType = optional(target.containerType)
if (!warehouseId || !channelGroupCode || !containerType) {
error.value = { message: '当前模拟柜缺少仓库、计划渠道或柜型,无法查询可关联订舱' }
return
}
const sequence = ++requestSequence
loading.value = true
try {
const result = await listOceanBookingLinkCandidates({ warehouseId, channelGroupCode, containerType, page: 1, size: 200 })
if (sequence !== requestSequence || !props.modelValue || props.container?.id !== target.id) return
candidates.value = result.records.map((candidate) => ({
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (cause) {
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function save() {
const target = props.container
const candidate = candidates.value.find((item) => item.key === candidateKey.value)
const normalized = normalizeContainerNo(containerNo.value)
containerNo.value = normalized
if (!target || !candidate) {
error.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }
return
}
saving.value = true
error.value = null
try {
const linked = await linkContainerOceanBooking(target.id, {
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
containerNo: normalized || null,
expectedVersion: target.version,
})
close()
ElMessage.success(normalized ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
emit('linked', linked)
} catch (cause) {
error.value = getApiErrorDetails(cause, '关联真实订舱柜失败')
} finally { saving.value = false }
}
watch(() => props.modelValue, (open) => {
if (open) void loadCandidates()
else {
requestSequence += 1
loading.value = false
}
}, { immediate: true })
defineExpose({ candidates, candidateKey, containerNo, save })
</script>
<template>
<AppDialog :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 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>
<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: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
</style>
@@ -3,7 +3,8 @@ import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
import { ref } from 'vue'
@@ -15,10 +16,11 @@ const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, PalletLabelPrintDialog: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } }
@@ -40,6 +42,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
})
@@ -98,6 +101,29 @@ describe('ContainerDetailView empty return', () => {
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
})
it('falls back to confirmed loading facts for destination and terminal drayage for unloading time', async () => {
vi.mocked(getContainer).mockResolvedValue({
...base,
destinationWarehouseCode: null,
destinationWarehouseName: null,
warehouseId: '8',
terminalTruckingCompany: null,
actualFacts: {},
} as never)
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getConfirmedLoadingList).mockResolvedValue({
planNo: 'LP-1',
items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }],
} as never)
const wrapper = await mountDetail()
expect(getConfirmedLoadingList).toHaveBeenCalledExactlyOnceWith('7001', '8')
expect(wrapper.text()).toContain('ABE4')
expect(wrapper.text()).toContain('港运车队')
expect(wrapper.text()).toContain('2026-09-07 10:00')
})
it.each([
['缺权限', false, ['RECORD_EMPTY_RETURN'], ['RECORD_EMPTY_RETURN'], 'UNLOADED'],
['缺服务端动作', true, [], [], 'UNLOADED'],
@@ -111,7 +137,7 @@ describe('ContainerDetailView empty return', () => {
})
describe('domestic container loading list and booking link', () => {
const domestic = { ...base, containerNo: null, warehouseId: '8', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
const domestic = { ...base, containerNo: null, warehouseId: '8', channelCode: 'ZBYHK', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
const loadingDetail = { planNo: 'LP-1', items: [] }
beforeEach(() => {
@@ -122,6 +148,8 @@ describe('domestic container loading list and booking link', () => {
vi.mocked(getContainerOverview).mockReset().mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getPreloadPlan).mockReset().mockResolvedValue(loadingDetail as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue(loadingDetail as never)
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(linkContainerOceanBooking).mockReset()
})
async function mountDomestic() {
@@ -161,6 +189,11 @@ describe('domestic container loading list and booking link', () => {
expect(tabs.find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe(permitted ? '装柜清单' : '送柜报关')
expect(getPreloadPlan).toHaveBeenCalledTimes(permitted && planStatus !== 'COMPLETED' ? 1 : 0)
expect(getConfirmedLoadingList).toHaveBeenCalledTimes(permitted && planStatus === 'COMPLETED' ? 1 : 0)
if (permitted) {
const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(drawer.props('mode')).toBe(planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
expect(drawer.props('embedded')).not.toBeUndefined()
}
wrapper.unmount()
}
}
@@ -193,7 +226,24 @@ describe('domestic container loading list and booking link', () => {
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
expect(link).toBeDefined()
await link.trigger('click')
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
await flushPromises()
expect(routerPush).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
})
it('refreshes the routed detail after the shared link dialog succeeds', async () => {
const linked = { ...domestic, oceanBookingId: '101', billOfLadingNo: 'BL-101', version: 2 }
vi.mocked(getContainer).mockResolvedValueOnce(domestic as never).mockResolvedValueOnce(linked as never)
const wrapper = await mountDomestic()
await wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent({ name: 'OceanBookingLinkDialog' })
expect(dialog.props('modelValue')).toBe(true)
dialog.vm.$emit('linked', linked)
await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('BL-101')
expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false)
})
it('keeps the same cabinet link before and after an inbound assignment snapshot refresh', async () => {
@@ -210,7 +260,9 @@ describe('domestic container loading list and booking link', () => {
expect(link.isVisible()).toBe(true)
routerPush.mockClear()
await link.trigger('click')
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
await flushPromises()
expect(routerPush).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
wrapper.unmount()
}
})
@@ -34,6 +34,8 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('getPreloadPlan')
expect(source).toContain('label="装柜清单"')
expect(source).toContain('打印装柜清单')
expect(source).toContain("const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')")
expect(source).toContain('<LoadingListDetailDrawer :mode="loadingListMode" embedded')
expect(source).toContain(':sections="[\'pda\']"')
})
@@ -129,6 +131,8 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain("!detail.value.oceanBookingId")
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
expect(source).toContain('关联已放舱订舱')
expect(source).toContain("query: { linkContainerId: detail.value.id }")
expect(source).toContain('oceanBookingLinkOpen.value = true')
expect(source).toContain('<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />')
expect(source).not.toContain('linkContainerId')
})
})
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
@@ -21,6 +21,8 @@ import {
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import {
cancelContainerException,
completeContainerClearance,
@@ -83,6 +85,12 @@ const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS'))
const deliveryOrderBlockedReason = computed(() => {
if (isSeaRailChannel(detail.value?.channelCode)) return '海铁渠道不适用 DO 资料'
if (detail.value?.planStatus !== 'COMPLETED' || !detail.value?.outboundConfirmedAt) return '货柜进入海外履约后才可上传'
if (detail.value?.actualEmptyReportedAt) return '货柜已还柜,不可再上传'
return null
})
const canRecordEmptyReturn = computed(() => isOverseas.value
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
&& hasAction('RECORD_EMPTY_RETURN')
@@ -140,6 +148,7 @@ const emptyReturnAt = ref('')
const emptyReturnReason = ref('')
const emptyReturnSaving = ref(false)
const emptyReturnError = ref<ApiErrorDetails | null>(null)
const oceanBookingLinkOpen = ref(false)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => 'DO 资料')
@@ -156,7 +165,20 @@ const overseasStageText = computed(() => {
} as Record<string, string>)[status || ''] || '海外履约中'
})
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const overseasDestinationText = computed(() => [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName].filter(Boolean).join(' / ') || '—')
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
? '装柜明细、PDA 证据与报关关联均以当前柜次为边界。'
: '核对预装明细与 PDA 作业准备情况,装柜完成后再处理整柜报关。')
const overseasDestinationText = computed(() => {
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
.filter(Boolean).join(' / ')
if (bookingDestination) return bookingDestination
const destinations = new Set((loadingDetail.value?.items || [])
.map((item) => String(item.destinationWarehouse || '').trim()).filter(Boolean))
return [...destinations].join('、') || '—'
})
const plannedChannelName = computed(() => getSmartLoadingChannelGroup(detail.value?.channelGroupCode)?.name || '—')
const emptyReturnWarningText = computed(() => {
const warning = workbenchDetail.value?.terminalDrayage?.emptyReturnWarning
if (!warning || warning.status === 'NOT_APPLICABLE') return '当前暂无还空预警'
@@ -196,7 +218,7 @@ const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNK
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
function openOceanBookingLink() {
if (!detail.value || !canLinkOceanBooking.value) return
void router.push({ name: 'domestic-container-workbench', query: { linkContainerId: detail.value.id } })
oceanBookingLinkOpen.value = true
}
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
@@ -275,6 +297,9 @@ async function load() {
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview'
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
if (isOverseas.value && resource.planStatus === 'COMPLETED' && canViewLoadingList.value) {
await loadLoadingDetail(resource, aggregate)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
@@ -354,6 +379,7 @@ function truckReferenceText(status?: string | null) {
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
if (deliveryOrderBlockedReason.value) { ElMessage.warning(deliveryOrderBlockedReason.value); return }
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
@@ -505,8 +531,8 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<h2 id="container-information-heading">货柜信息</h2>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ detail.channelName || '待关联订舱' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
@@ -518,7 +544,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" :delivery-order-blocked-reason="deliveryOrderBlockedReason" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
</section>
@@ -546,14 +572,14 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></header>
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div><el-button v-if="loadingDetail && loadingListMode === 'confirmed'" @click="printLoadingList">打印装柜清单</el-button></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer mode="confirmed" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
<LoadingListDetailDrawer :mode="loadingListMode" embedded :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
@@ -567,6 +593,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</section>
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form label-position="top"><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 v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-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 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 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>
@@ -11,7 +11,8 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerLoadingRoutes } from '../routes'
@@ -70,8 +71,8 @@ describe('ContainersView', () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -176,7 +177,7 @@ describe('ContainersView', () => {
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) =>
select.props('placeholder') === '请选择车队' && select.element.closest('.el-table__body'),
select.props('placeholder') === '输入车队名称或联系人搜索' && select.element.closest('.el-table__body'),
)
expect(fleet).toBeDefined()
expect(fleet!.props('disabled')).toBe(false)
@@ -320,7 +321,7 @@ describe('ContainersView', () => {
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', version: 0,
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
@@ -470,6 +471,7 @@ describe('ContainersView', () => {
const vm = wrapper.vm as unknown as {
rows: Array<Record<string, unknown>>
searchTerminalFleets: (keyword?: string) => Promise<void>
selectRowFleet: (row: Record<string, unknown>, supplierId: string) => Promise<void>
openDrayageAction: (row: Record<string, unknown>, action: 'RECORD_EMPTY_RETURN') => void
drayageActionAt: string
@@ -477,6 +479,7 @@ describe('ContainersView', () => {
}
const beforeFleet = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValueOnce({ ...beforeFleet, records: beforeFleet.records.map((row) => ({ ...row, terminalDrayage: { ...drayage, version: 4, fleetSupplierId: '502', allowedActions: [...drayage.allowedActions] } })) })
await vm.searchTerminalFleets('新车队')
await vm.selectRowFleet(vm.rows[0], '502')
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' })
@@ -490,6 +493,33 @@ describe('ContainersView', () => {
wrapper.unmount()
})
it('searches active terminal fleets remotely and ignores an older response', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
let resolveOld!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
let resolveLatest!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
vi.mocked(listSuppliers).mockReset()
.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveLatest = resolve }))
const vm = wrapper.vm as unknown as {
terminalFleetOptions: Array<{ id: string; name: string }>
searchTerminalFleets: (keyword?: string) => Promise<void>
}
const oldSearch = vm.searchTerminalFleets('旧关键词')
const latestSearch = vm.searchTerminalFleets('新车队')
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '旧关键词' })
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '新车队' })
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
await latestSearch
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
await oldSearch
expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })])
wrapper.unmount()
})
it('loads terminal drayage detail and history only when requested', async () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }])
const router = createTestRouter()
@@ -505,57 +535,10 @@ describe('ContainersView', () => {
wrapper.unmount()
})
it('consumes the booking-link action once when returning to a cached list and preserves other query state', async () => {
vi.mocked(getContainer).mockClear()
vi.mocked(listContainers).mockClear()
vi.mocked(listOceanBookingLinkCandidates).mockClear().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const shell = defineComponent({ template: '<router-view v-slot="{ Component }"><keep-alive><component :is="Component" /></keep-alive></router-view>' })
const wrapper = mount(shell, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const initial = wrapper.findComponent(ContainersView).vm.$.uid
await router.push('/container-loading/domestic/9001')
await flushPromises()
await router.push({ name: 'domestic-container-workbench', query: { linkContainerId: '9001', keyword: 'CB-1' }, hash: '#table' })
await flushPromises()
expect(wrapper.findComponent(ContainersView).vm.$.uid).toBe(initial)
expect(getContainer).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listContainers).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.query).toEqual({ keyword: 'CB-1' })
expect(router.currentRoute.value.hash).toBe('#table')
const dialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '关联已放舱订舱')!
expect(dialog.props('modelValue')).toBe(true)
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
await router.push('/container-loading/domestic/9001')
await router.push({ name: 'domestic-container-workbench' })
await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
expect(getContainer).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('ignores a stale linking response after leaving the list', async () => {
const target = await vi.mocked(getContainer).getMockImplementation()!('9001', 'DOMESTIC')
let finish!: (value: typeof target) => void
vi.mocked(getContainer).mockClear().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
vi.mocked(listOceanBookingLinkCandidates).mockClear()
const router = createTestRouter()
await router.push({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } }); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await router.replace({ query: { linkContainerId: '9001', keyword: 'updated' } })
await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(1)
await router.push('/container-loading/domestic/9001')
finish(target)
await flushPromises()
expect(listOceanBookingLinkCandidates).not.toHaveBeenCalled()
expect(router.currentRoute.value.name).toBe('domestic-container-detail')
wrapper.unmount()
it('owns the released-booking entry through the shared dialog without a detail redirect query', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />')
expect(source).not.toContain('linkContainerId')
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
@@ -578,69 +561,12 @@ describe('ContainersView', () => {
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 })
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 })
} finally {
wrapper.unmount()
}
})
it('normalizes an optional booking-link container number', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ' mscu1234567 '
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
})
expect(vm.linkContainerNo).toBe('MSCU1234567')
wrapper.unmount()
})
it('links a released booking while leaving the real container number pending until pickup', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ''
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
})
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('真实集装箱号可待司机提柜时录入')
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
expect(source).toContain("row.containerNo || '待补录'")
wrapper.unmount()
})
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
@@ -660,7 +586,7 @@ describe('ContainersView', () => {
expect(source).not.toContain('新增货柜')
expect(simulationSource).toContain('新增模拟柜')
expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })')
expect(simulationSource).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
@@ -677,7 +603,7 @@ describe('ContainersView', () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HC', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 })
@@ -691,9 +617,9 @@ describe('ContainersView', () => {
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HC', page: 1, size: 200 })
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: unknown[] }).candidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/OceanBookingLinkDialog.vue'), 'utf8'))
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
expect(getOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
@@ -715,7 +641,7 @@ describe('ContainersView', () => {
await flushPromises()
const label = `OB-OPTIONAL-CUSTOMER · 40HC × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
expect(document.body.textContent).toContain(label)
expect((wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates[0]?.label).toBe(label)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
wrapper.unmount()
})
@@ -724,7 +650,7 @@ describe('ContainersView', () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HC', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
@@ -738,15 +664,16 @@ describe('ContainersView', () => {
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
linkAction.click()
await flushPromises()
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HC', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HC × 1')
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
wrapper.unmount()
})
@@ -780,7 +707,7 @@ describe('ContainersView', () => {
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
const candidateLabels = (wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label)
const candidateLabels = (wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates.map((candidate) => candidate.label)
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
wrapper.unmount()
@@ -868,7 +795,10 @@ describe('ContainersView trucking supplier contract', () => {
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })")
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'")
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
expect(source).toContain(':remote-method="searchTerminalFleets"')
expect(source).toContain('selectTruckingSupplier')
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).not.toContain('allow-create')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -9,7 +9,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOceanBookingLinkCandidates, listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -19,7 +19,9 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import {
getTerminalDrayageEmails,
sendTerminalDrayageEmail,
@@ -43,7 +45,6 @@ type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabi
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const router = useRouter()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<ContainerWorkbenchRow[]>([])
const customers = ref<PlatformTenantResource[]>([])
@@ -52,6 +53,8 @@ const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const truckingSuppliers = ref<Supplier[]>([])
const terminalFleetOptions = ref<Supplier[]>([])
const terminalFleetLoading = ref(false)
const oceanBookings = ref<OceanBookingResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
@@ -71,13 +74,7 @@ const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const linkOpen = ref(false)
const linkLoading = ref(false)
const linkSaving = ref(false)
const linkError = ref<ApiErrorDetails | null>(null)
const linkTarget = ref<ContainerBookingResource | null>(null)
const linkCandidateKey = ref('')
const linkContainerNo = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
@@ -155,12 +152,12 @@ async function retryEmail(mail: TerminalDrayageEmail) {
}
let listSequence = 0
let detailSequence = 0
let linkSequence = 0
let warehousesLoaded = false
let warehouseOptionsPromise: Promise<void> | null = null
let partnersLoaded = false
let truckingSuppliersLoaded = false
let truckingSuppliersPromise: Promise<Supplier[]> | null = null
let terminalFleetSearchSequence = 0
let oceanBookingsLoaded = false
let businessOptionsPromise: Promise<void> | null = null
@@ -231,7 +228,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const allColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
@@ -243,6 +240,8 @@ const allColumns: DataTableColumn[] = [
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' },
{ key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' },
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
{ key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' },
{ key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' },
@@ -260,7 +259,7 @@ const allColumns: DataTableColumn[] = [
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
@@ -276,7 +275,6 @@ const drayageHistoryColumns: DataTableColumn[] = [
]
function optional(value: string | null) { return value?.trim() || null }
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -473,10 +471,26 @@ async function loadTruckingSuppliers(): Promise<Supplier[]> {
try { return await truckingSuppliersPromise }
finally { truckingSuppliersPromise = null }
}
async function ensureTruckingSuppliers(open: unknown = true) {
if (!open) return false
try { await loadTruckingSuppliers(); return true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '拖车供应商加载失败').message); return false }
async function searchTerminalFleets(keyword = '') {
const sequence = ++terminalFleetSearchSequence
terminalFleetLoading.value = true
try {
const result = await listSuppliers({
page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined,
})
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
} catch (error) {
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = []
ElMessage.error(getApiErrorDetails(error, '码头车队搜索失败').message)
} finally {
if (sequence === terminalFleetSearchSequence) terminalFleetLoading.value = false
}
}
function handleTerminalFleetVisible(open: boolean) {
if (open) void searchTerminalFleets()
else { terminalFleetSearchSequence++; terminalFleetLoading.value = false }
}
async function loadAllPartners() {
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
@@ -495,61 +509,10 @@ async function loadSimulationWarehouses() {
if (warehouseError.value) throw warehouseError.value
return warehouses.value
}
async function openLink(source: object) {
if (linkLoading.value) return
function openLink(source: object) {
const row = source as ContainerBookingResource
const warehouseId = optional(row.warehouseId)
const channelCode = optional(row.channelCode)
if (!warehouseId || !channelCode || !optional(row.containerType)) {
linkTarget.value = row
linkCandidates.value = []
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
linkOpen.value = true
return
}
const sequence = ++linkSequence
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
linkCandidates.value = []
linkError.value = null
linkOpen.value = true
linkLoading.value = true
try {
const result = await listOceanBookingLinkCandidates({
warehouseId, channelCode, containerType: row.containerType,
page: 1, size: 200,
})
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
linkCandidates.value = result.records.map((candidate) => ({
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
disabled: false,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (error) {
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
} finally {
if (sequence === linkSequence) linkLoading.value = false
}
}
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const normalized = normalizedContainerNo(linkContainerNo.value)
const containerNo = normalized || null
linkContainerNo.value = normalized
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
linkSaving.value = true
linkError.value = null
try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
}
function replaceDrayage(value: TerminalDrayageView) {
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
@@ -562,13 +525,16 @@ async function refreshDrayage(containerBookingId: string) {
async function selectRowFleet(source: object, supplierIdValue: unknown) {
const row = source as ContainerBookingResource
const current = drayageFor(row.id)
if (!(await ensureTruckingSuppliers())) return
const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || ''))
const supplierId = String(supplierIdValue || '')
const supplier = terminalFleetOptions.value.find((item) => item.id === supplierId)
|| truckingCompanies.value.find((item) => item.id === supplierId)
if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
try {
replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name }))
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id ? '码头车队已更新,取消及提柜邮件已加入发送队列' : '码头车队已更新')
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id
? '码头车队已更新,新车队提柜邮件已入队;原车队已下发过邮件时同时加入取消邮件'
: '码头车队已更新')
await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '码头车队更新失败')
@@ -709,37 +675,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
watch(linkOpen, (open) => {
if (open) return
linkSequence += 1
linkLoading.value = false
})
let routeLinkSequence = 0
watch([() => route.name, () => route.query.linkContainerId, isDomestic, canEdit], async ([name, requestedId, domestic, editable]) => {
const sequence = ++routeLinkSequence
const linkContainerId = typeof requestedId === 'string' ? requestedId : ''
if (name !== 'domestic-container-workbench' || !domestic || !linkContainerId || !editable) return
try {
const target = await getContainer(linkContainerId, 'DOMESTIC')
if (sequence !== routeLinkSequence) return
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !hasAction(target, 'EDIT_CONTAINER')) {
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
return
}
await openLink(target)
} catch (error) {
if (sequence === routeLinkSequence) ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
} finally {
if (sequence === routeLinkSequence && route.query.linkContainerId === linkContainerId) {
const { linkContainerId: consumed, ...query } = route.query
void router.replace({ path: route.path, query, hash: route.hash })
}
}
}, { immediate: true })
onBeforeUnmount(() => { routeLinkSequence += 1 })
onMounted(async () => {
await Promise.all([loadCustomers(), loadRows()])
})
onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</script>
<template>
@@ -766,7 +702,7 @@ onMounted(async () => {
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @visible-change="ensureTruckingSuppliers" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
@@ -784,12 +720,7 @@ onMounted(async () => {
</section>
</ListPageLayout>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号(可选)"><el-input v-model="linkContainerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :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>
@@ -840,7 +771,7 @@ onMounted(async () => {
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.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%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.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%; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
@@ -7,7 +7,6 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -64,7 +63,7 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups:
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船',
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
@@ -100,7 +99,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
return {
records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '',
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode,
channelCode: item.channelCode, channelName: item.channelName,
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
})),
@@ -110,7 +110,7 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
function mockContainerSearch(records: ContainerBookingResource[]) {
vi.mocked(listSmartLoadingWorkbenchContainers).mockImplementation(async (query) => containerPage(records.filter((item) => (
item.planStatus !== 'COMPLETED'
&& query.channelGroupCodes.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '')
&& query.channelGroupCodes.includes(item.channelGroupCode)
))))
}
@@ -233,6 +233,7 @@ describe('GlobalSmartLoadingView', () => {
planId: '9007199254740999',
cabinetNo: 'CB-2',
containerNo: 'MSCU-2',
channelGroupCode: 'HEDE',
channelCode: 'HDHK',
channelName: '合德海卡包税',
}
@@ -267,6 +268,7 @@ describe('GlobalSmartLoadingView', () => {
planId: '9007199254740999',
cabinetNo: 'CB-2',
containerNo: 'MSCU-2',
channelGroupCode: 'HEDE',
channelCode: 'HDHK',
channelName: '合德海卡包税',
}
@@ -351,6 +353,7 @@ describe('GlobalSmartLoadingView', () => {
containerNo: 'MSCU-DG-MAX',
warehouseId: '18',
warehouseName: '东莞仓',
channelGroupCode: 'MATSON_MAX',
channelCode: 'MATSON_MAX_TRUCK',
channelName: '美森加班海卡',
}
@@ -493,7 +496,7 @@ describe('GlobalSmartLoadingView', () => {
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班海卡包税')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -510,8 +513,8 @@ describe('GlobalSmartLoadingView', () => {
inboundId: null,
orderStatusSnapshot: 'PENDING_REVIEW',
inboundCode: 'EXXHK2609020361',
channelCode: 'SLOW',
channelName: '慢船',
channelCode: 'HEDE_TRUCK',
channelName: '合德海卡',
unitGrossWeightKg: null,
averageUnitVolumeCbm: null,
availableVolumeCbm: null,
@@ -530,7 +533,7 @@ describe('GlobalSmartLoadingView', () => {
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
expect(document.body.textContent).toContain('订单渠道:慢船')
expect(document.body.textContent).toContain('订单渠道:合德海卡')
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
;([...document.body.querySelectorAll('button')]
@@ -711,7 +714,7 @@ describe('GlobalSmartLoadingView', () => {
})
it('isolates a missing-channel row without hiding another valid container', async () => {
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null }
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelGroupCode: '', channelCode: null, channelName: null }
vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 })
mockContainerSearch([missingChannel, container])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
@@ -77,19 +77,23 @@ let containerRequest = 0
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null)
const crossChannelAssignment = computed(() => Boolean(
selectedCandidate.value?.channelCode
&& selectedContainer.value?.channelCode
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
))
function containerChannelGroup(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return null
return SMART_LOADING_CHANNEL_GROUPS.find((group) => group.code === container.channelGroupCode) || null
}
const crossChannelAssignment = computed(() => {
const candidateGroup = resolveSmartLoadingChannelGroup(selectedCandidate.value?.channelCode, selectedCandidate.value?.channelName)
const containerGroup = containerChannelGroup(selectedContainer.value)
return Boolean(candidateGroup && containerGroup && candidateGroup.code !== containerGroup.code)
})
const orchestratableContainers = computed(() => containers.value)
const completedContainerCount = computed(() => 0)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelGroupCode).length)
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
item.channelGroupCode && !containerChannelGroup(item)
)).length)
const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
containerChannelGroup(item)
)).length)
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const warehouseOptions = computed(() => {
@@ -675,8 +679,8 @@ onMounted(() => { void load(); void loadCustomers() })
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的渠道未纳入固定 9 类,已隔离`" />
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少计划渠道,已隔离;请重新创建模拟柜`" />
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的计划渠道未纳入固定 9 类,已隔离`" />
<el-alert v-if="completedContainerCount && !orchestratableContainers.length" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
@@ -760,7 +764,7 @@ onMounted(() => { void load(); void loadCustomers() })
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.name || '渠道待补充' }}</span>
<span>渠道:{{ containerChannelGroup(item)?.name || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
@@ -817,7 +821,7 @@ onMounted(() => { void load(); void loadCustomers() })
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<template v-if="selectedCandidate.coLoadGroupId">
@@ -19,7 +19,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
@@ -19,7 +19,7 @@ import {
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
const route = useRoute()
const router = useRouter()
@@ -210,7 +210,7 @@ async function load() {
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
else throw error
}
const channelGroup = resolveSmartLoadingChannelGroup(container.value.channelCode, container.value.channelName)
const channelGroup = getSmartLoadingChannelGroup(container.value.channelGroupCode)
candidates.value = plan.value?.canAssign && channelGroup
? (await listSmartLoadingCandidates({ channelGroupCodes: [channelGroup.code], warehouseId: container.value.warehouseId, page: 1, size: 200 })).records
: []
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
isSeaRailChannel,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from './smartLoadingChannelGroups'
@@ -33,6 +34,11 @@ describe('smartLoadingChannelGroups', () => {
expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull()
})
it('identifies canonical and legacy sea-rail channel codes', () => {
expect(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'].every(isSeaRailChannel)).toBe(true)
expect(isSeaRailChannel('OA_LA_TRUCK')).toBe(false)
})
it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => {
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
@@ -15,6 +15,12 @@ export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
] as const
const SEA_RAIL_CHANNEL_CODES = new Set(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'])
export function isSeaRailChannel(channelCode?: string | null) {
return SEA_RAIL_CHANNEL_CODES.has(String(channelCode || '').trim().toUpperCase())
}
export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [
{
code: 'MATSON_CLX',
@@ -63,6 +69,11 @@ export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] =
},
] as const
export function getSmartLoadingChannelGroup(groupCode?: string | null) {
const normalizedCode = String(groupCode || '').trim().toUpperCase()
return SMART_LOADING_CHANNEL_GROUPS.find((group) => group.code === normalizedCode) || null
}
export function resolveSmartLoadingChannelGroup(channelCode?: string | null, channelName?: string | null) {
const normalizedCode = String(channelCode || '').trim().toUpperCase()
if (normalizedCode) {
+6 -3
View File
@@ -12,6 +12,7 @@ export interface ContainerBookingResource {
mergedIntoContainerBookingId?: string | null
warehouseId: string
warehouseName: string | null
channelGroupCode: string
channelCode: string | null
channelName: string | null
oceanBookingId?: string | null
@@ -55,6 +56,7 @@ export interface ContainerBookingResource {
destinationClearanceDocumentStatus?: ContainerDocumentStatus
customsDeclarationDocumentStatus?: ContainerDocumentStatus
deliveryOrderDocumentStatus?: ContainerDocumentStatus
outboundConfirmedAt?: string | null
version: number
createdAt?: string | null
updatedAt?: string | null
@@ -62,7 +64,7 @@ export interface ContainerBookingResource {
}
export interface ContainerSimulationCreateRequest {
channelCode: string
channelGroupCode: string
warehouseId: string
containerType: string
capacityCbm: number
@@ -374,8 +376,9 @@ export interface SmartLoadingWorkbenchContainer {
containerNo: string | null
warehouseId: string
warehouseName: string
channelCode: string
channelName: string
channelGroupCode: string
channelCode: string | null
channelName: string | null
containerType: string
capacityCbm: number | null
maxWeightKg: number | null
@@ -13,6 +13,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import AppDialog from '@/shared/components/AppDialog.vue'
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() }))
vi.mock('@/modules/fba-pricing', () => ({
@@ -21,6 +22,7 @@ vi.mock('@/modules/fba-pricing', () => ({
retireCustomerChargeRule: vi.fn(),
setCustomerChargeRuleDefault: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({ getPlatformTenantDimensionInitializationStatus: vi.fn() }))
const boxes = [
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
@@ -64,6 +66,10 @@ describe('CustomerBillingRulesPanel', () => {
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
vi.mocked(createCustomerChargeRule).mockResolvedValue(chargeRule)
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValue({
tenantId: '8', status: 'PENDING', attemptCount: 0, nextRetryAt: null,
lastErrorCode: null, lastErrorMessage: null, updatedAt: null,
})
})
it('renders the four fixed carton dimension rows and calculated values', async () => {
@@ -93,6 +99,42 @@ describe('CustomerBillingRulesPanel', () => {
expect(getCustomerDimensionConfig).not.toHaveBeenCalled()
})
it('does not request billing rules before customer defaults are ready', async () => {
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: {
customerId: '8',
customerName: '柏悦物流',
dimensionInitializationStatus: 'PENDING',
},
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(document.body.textContent).toContain('客户计费规则正在初始化')
expect(document.body.textContent).not.toContain('箱规尺寸规则加载失败')
expect(getCustomerDimensionConfig).not.toHaveBeenCalled()
expect(listCustomerChargeRules).not.toHaveBeenCalled()
})
it('loads billing rules automatically when asynchronous initialization becomes ready', async () => {
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValueOnce({
tenantId: '8', status: 'READY', attemptCount: 1, nextRetryAt: null,
lastErrorCode: null, lastErrorMessage: null, updatedAt: '2026-09-10T20:04:06',
})
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流', dimensionInitializationStatus: 'PENDING' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(getPlatformTenantDimensionInitializationStatus).toHaveBeenCalledWith('8')
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
expect(listCustomerChargeRules).toHaveBeenCalledWith('8')
expect(document.body.textContent).not.toContain('箱规尺寸规则加载失败')
})
it('submits all four rows in one update request', async () => {
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Pencil, Ruler, Save } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,13 +18,24 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
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'
const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
const props = defineProps<{
customerId: string
customerName: string
customerCode?: string
dimensionInitializationStatus?: string
}>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('firstleg:dimension-config:view'))
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
const observedInitializationStatus = ref(props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY')
const initializationStatus = computed(() => observedInitializationStatus.value)
const initializationPending = computed(() => ['PENDING', 'SENDING'].includes(initializationStatus.value))
const initializationFailed = computed(() => initializationStatus.value === 'FAILED')
const initializationReady = computed(() => !initializationPending.value && !initializationFailed.value)
const config = ref<CustomerDimensionConfig | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -34,6 +45,8 @@ const editorDirty = ref(false)
const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL')
const defaultModeSaving = ref(false)
const editorForm = ref<FormInstance>()
let initializationPollTimer: ReturnType<typeof setTimeout> | null = null
let initializationPollGeneration = 0
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
const chargeRules = ref<CustomerChargeRule[]>([])
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
@@ -90,18 +103,64 @@ watch(form, () => {
}, { deep: true })
watch(() => props.customerId, () => {
stopInitializationPolling()
observedInitializationStatus.value = props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY'
editorVisible.value = false
defaultCalculationMode.value = 'ACTUAL'
void loadConfig()
if (initializationReady.value) {
void loadConfig()
void loadChargeRules()
} else if (initializationPending.value) {
startInitializationPolling()
}
})
watch(() => props.dimensionInitializationStatus, (status) => {
observedInitializationStatus.value = status?.trim().toUpperCase() || 'READY'
if (initializationPending.value) startInitializationPolling()
else stopInitializationPolling()
})
watch(initializationReady, (ready, previous) => {
if (ready && !previous) {
void loadConfig()
void loadChargeRules()
}
})
function stopInitializationPolling() {
initializationPollGeneration += 1
if (initializationPollTimer) clearTimeout(initializationPollTimer)
initializationPollTimer = null
}
function startInitializationPolling() {
if (initializationPollTimer) return
const generation = ++initializationPollGeneration
const tenantId = props.customerId
const poll = async () => {
initializationPollTimer = null
try {
const result = await getPlatformTenantDimensionInitializationStatus(tenantId)
if (generation !== initializationPollGeneration || tenantId !== props.customerId) return
observedInitializationStatus.value = result.status?.trim().toUpperCase() || 'PENDING'
} catch {
// Initialization is retried by IAM. A transient diagnostic failure must not trigger the dimension API early.
}
if (generation === initializationPollGeneration && tenantId === props.customerId && initializationPending.value) {
initializationPollTimer = setTimeout(poll, 2_000)
}
}
void poll()
}
function sortRules(rules: CustomerDimensionRule[]) {
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
}
async function loadConfig() {
if (!canView.value) return
if (!canView.value || !initializationReady.value) return
loading.value = true
loadError.value = null
try {
@@ -266,7 +325,7 @@ function formatDate(value: string | null) {
}
async function loadChargeRules() {
if (!canChargeView.value) return
if (!canChargeView.value || !initializationReady.value) return
chargeLoading.value = true
try {
chargeRules.value = await listCustomerChargeRules(props.customerId)
@@ -287,17 +346,35 @@ async function loadChargeRules() {
finally { chargeLoading.value = false }
}
onMounted(() => { void loadConfig(); void loadChargeRules() })
onMounted(() => {
if (initializationPending.value) {
startInitializationPolling()
return
}
if (!initializationReady.value) return
void loadConfig()
void loadChargeRules()
})
onBeforeUnmount(stopInitializationPolling)
</script>
<template>
<div class="dimension-rule-panel">
<div v-if="!canView || loadError" class="panel-title rule-heading">
<div v-if="initializationPending" class="rule-state initialization-state" role="status">
<Ruler :size="30" />
<strong>客户计费规则正在初始化</strong>
<p>默认箱规和权重系数正在后台准备,完成后本页会自动加载。</p>
</div>
<div v-else-if="initializationFailed" class="rule-error initialization-state" role="alert">
<div><strong>客户计费规则初始化失败</strong><p>请在客户管理中重试初始化,成功后再查看计费规则。</p></div>
</div>
<div v-if="initializationReady && (!canView || loadError)" class="panel-title rule-heading">
<div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div>
</div>
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
<section v-else-if="config" class="rule-card">
<div v-if="initializationReady && !canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
<div v-else-if="initializationReady && loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
<section v-else-if="initializationReady && config" class="rule-card">
<header class="rule-card-heading">
<div class="rule-title-row">
<h2>按箱规尺寸计算</h2>
@@ -315,9 +392,9 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
<template #empty><div class="rule-state"><Ruler :size="28" /><strong>当前客户暂无箱规尺寸规则</strong></div></template>
</DataTable>
</section>
<div v-else v-loading="loading" class="loading-state" />
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
<section v-if="canChargeView" class="rule-card charge-rule-card">
<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>
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
@@ -9,7 +9,6 @@ import {
listTenantQualifications,
type PlatformTenantResource,
} from '@/modules/iam'
import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerDetailView from './CustomerDetailView.vue'
@@ -39,14 +38,6 @@ vi.mock('@/modules/iam', () => ({
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
}))
vi.mock('@/modules/first-leg-orders', () => ({
listPlatformFirstLegOrders: vi.fn(),
}))
vi.mock('@/modules/finance', () => ({
CustomerStatementsPanel: { template: '<div>财务账单</div>' },
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
@@ -90,17 +81,6 @@ describe('CustomerDetailView', () => {
membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三',
phone: '13800138000', email: 'ops@example.com', memberType: 'OWNER', memberNo: null, status: 'ACTIVE', roleIds: [], roleNames: ['企业管理员'],
}])
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({
records: [{
id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName,
status: 'PROCESSING', transportMode: 'SEA', tradeTerm: null, destinationCountryCode: 'US', destinationWarehouseId: null,
destinationWarehouseName: '洛杉矶', expectedShipDate: '2026-08-08', customerRemark: null, pickupMethod: 'WAREHOUSE',
receivingWarehouseId: 'warehouse-1', receivingWarehouseName: '深圳头程收货仓', receivingWarehouseAddress: '深圳市', receivingTime: null,
channelCode: 'SEA_FCL', channelName: '海运整箱', taxMode: 'DDP', containerMode: 'FULL_CONTAINER', customsInspection: false,
destinationType: 'FBA', amazonWarehouseCode: null, shipmentId: null, referenceId: null, expressCompany: null, expressTrackingNo: null,
createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:00:00',
} satisfies PlatformFirstLegOrderResource], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
})
@@ -109,7 +89,7 @@ describe('CustomerDetailView', () => {
await flushPromises()
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
'基本信息', '运单记录', '财务账单', '计费规则', '操作日志',
'基本信息', '计费规则', '操作日志',
])
expect(document.body.textContent).not.toContain('累计运单')
expect(document.body.textContent).not.toContain('累计应收')
@@ -123,14 +103,6 @@ describe('CustomerDetailView', () => {
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
expect(document.body.textContent).toContain('已替换')
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
ordersTab?.click()
await flushPromises()
expect(document.body.textContent).toContain('BY20260807001')
expect(document.body.textContent).toContain('海运整箱 FCL')
expect(document.body.textContent).not.toContain('暂无尺寸计算规则查看权限')
expect(listPlatformFirstLegOrders).toHaveBeenCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
const billingTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="计费规则"]')
billingTab?.click()
await flushPromises()
@@ -138,23 +110,6 @@ describe('CustomerDetailView', () => {
expect(document.body.textContent).not.toContain('计费规则尚未接入')
})
it('does not expose unknown order status codes', async () => {
const result = await listPlatformFirstLegOrders({ tenantId: String(tenant.id), page: 1, size: 10 })
vi.mocked(listPlatformFirstLegOrders).mockResolvedValueOnce({
...result,
records: result.records.map((order) => ({ ...order, status: 'LEGACY_STATUS' as PlatformFirstLegOrderResource['status'] })),
})
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
ordersTab?.click()
await flushPromises()
expect(document.body.textContent).toContain('未知状态')
expect(document.body.textContent).not.toContain('LEGACY_STATUS')
})
it('opens an authenticated qualification preview', async () => {
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -167,28 +122,6 @@ describe('CustomerDetailView', () => {
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
})
it('does not duplicate the first page request when retrying from a later page', async () => {
const wrapper = mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
vi.mocked(listPlatformFirstLegOrders).mockRejectedValueOnce(new Error('暂时不可用'))
const vm = wrapper.vm as unknown as { orderPage: number }
vm.orderPage = 2
await flushPromises()
expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(2)
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
ordersTab?.click()
await flushPromises()
const retryButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('重新加载'))
retryButton?.click()
await flushPromises()
expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(3)
expect(listPlatformFirstLegOrders).toHaveBeenLastCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
})
it('reloads a reused detail view and ignores the previous customer response', async () => {
const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' }
let resolveFirstTenant!: (value: PlatformTenantResource) => void
@@ -196,8 +129,6 @@ describe('CustomerDetailView', () => {
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(listPlatformTenantMembers).mockResolvedValue([])
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
routeControl.setTenantId('9')
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, Building2, Eye,
FileCheck2, FileText, Pencil, Power,
Ship, UserRound, UsersRound,
UserRound, UsersRound,
} from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -19,15 +19,8 @@ import {
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
import {
listPlatformFirstLegOrders,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/modules/first-leg-orders'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -36,10 +29,9 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
import { CustomerStatementsPanel } from '@/modules/finance'
import { CustomerAuditLogsPanel } from '@/modules/audit'
type DetailTab = 'basic' | 'orders' | 'statements' | 'billing' | 'logs'
type DetailTab = 'basic' | 'billing' | 'logs'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute()
@@ -53,15 +45,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const customer = ref<PlatformTenantResource | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
const members = ref<PlatformTenantMember[]>([])
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
const customerOrderTotal = ref(0)
const orderPage = ref(1)
const orderPageSize = ref(10)
const customerOrdersLoading = ref(false)
const customerOrdersError = ref<ApiErrorDetails | null>(null)
let skipNextOrderPageLoad = false
let detailRequestSerial = 0
let orderRequestSerial = 0
let disposed = false
const previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
@@ -72,24 +56,11 @@ const tabList = ref<HTMLElement | null>(null)
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'orders', label: '运单记录', icon: '📦' },
{ key: 'statements', label: '财务账单', icon: '🧾' },
{ key: 'billing', label: '计费规则', icon: '⚖️' },
{ key: 'logs', label: '操作日志', icon: '📝' },
]
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '状态', width: 106, slotName: 'status' },
{ key: 'orderNo', label: '运单号', minWidth: 180, slotName: 'orderNo' },
{ key: 'transport', label: '运输方式', minWidth: 150, slotName: 'transport' },
{ key: 'destination', label: '目的地', minWidth: 170, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'expectedShipDate', label: '预计出运', width: 128, slotName: 'expectedShipDate' },
{ key: 'createdAt', label: '创建时间', width: 150, slotName: 'createdAt' },
]
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatCapital(value: number | string | null, currency: string | null) {
@@ -120,36 +91,6 @@ function qualificationName(item: PlatformTenantQualification) {
try { return qualificationFileName(item.documentUrl) }
catch { return `${qualificationLabel(item.qualificationType)}文件` }
}
function orderStatusLabel(status: PlatformFirstLegOrderStatus) {
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[status] || '未知状态'
}
function transportLabel(order: PlatformFirstLegOrderResource) {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
if (order.transportMode === 'AIR') return '空运'
if (order.transportMode === 'RAIL') return '铁路运输'
return '陆运'
}
function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) {
if (!access.can('platform:order:list')) return
const serial = ++orderRequestSerial
customerOrdersLoading.value = true
customerOrdersError.value = null
try {
const result = await listPlatformFirstLegOrders({ tenantId: requestedTenantId, page, size: orderPageSize.value })
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = result.records
customerOrderTotal.value = result.total
} catch (error) {
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = []
customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试')
} finally {
if (!disposed && serial === orderRequestSerial) customerOrdersLoading.value = false
}
}
function clearPreviews() {
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
previews.value = {}
@@ -195,9 +136,6 @@ async function loadDetail() {
customer.value = null
qualifications.value = []
members.value = []
customerOrders.value = []
customerOrderTotal.value = 0
customerOrdersError.value = null
try {
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
getPlatformTenant(requestedTenantId),
@@ -211,17 +149,6 @@ async function loadDetail() {
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
} satisfies PreviewState]))
if (access.can('platform:order:list')) {
if (orderPage.value !== 1) skipNextOrderPageLoad = true
orderPage.value = 1
void loadCustomerOrders(1, requestedTenantId)
} else {
customerOrdersLoading.value = false
customerOrdersError.value = null
customerOrders.value = []
customerOrderTotal.value = 0
}
} catch (error) {
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = null
@@ -233,10 +160,6 @@ async function loadDetail() {
}
}
function openOrder(order: PlatformFirstLegOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(order.id) }, query: { tenantId: tenantId.value } })
}
async function changeStatus() {
if (!customer.value || statusChanging.value) return
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
@@ -272,35 +195,25 @@ watch(tenantId, (value) => {
if (value) void loadDetail()
else {
detailRequestSerial += 1
orderRequestSerial += 1
loading.value = false
customer.value = null
qualifications.value = []
members.value = []
customerOrders.value = []
}
}, { immediate: true })
onBeforeUnmount(() => {
disposed = true
detailRequestSerial += 1
orderRequestSerial += 1
clearPreviews()
})
watch(orderPage, (page, previousPage) => {
if (skipNextOrderPageLoad) {
skipNextOrderPageLoad = false
return
}
if (page !== previousPage) void loadCustomerOrders(page)
})
</script>
<template>
<!--
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
STORY: 运营先确认企业状态,再从六个业务入口查看资料、用户和后续记录。
FIRST VIEWPORT: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
STORY: 运营先确认企业状态,再查看客户资料、计费规则和操作日志。
FIRST VIEWPORT: 客户身份与动作居首,三个页签形成稳定内容索引。
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
@@ -391,28 +304,8 @@ watch(orderPage, (page, previousPage) => {
</div>
</template>
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单,共 {{ customerOrderTotal }} 票</p></div><Ship :size="19" /></div>
<template v-if="access.can('platform:order:list')">
<el-alert v-if="customerOrdersError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ customerOrdersError.message }}</span><ApiErrorMeta :details="customerOrdersError" /></div></template><el-button link type="danger" @click="loadDetail">重新加载</el-button></el-alert>
<DataTable v-else v-model:page="orderPage" v-model:page-size="orderPageSize" :data="customerOrders" :columns="orderColumns" :loading="customerOrdersLoading" :total="customerOrderTotal" :page-sizes="[10]" empty-text="当前客户暂无运单记录" row-key="id">
<template #actions="{ row }"><el-button link type="primary" @click="openOrder(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button></template>
<template #status="{ row }"><StatusBadge :status="orderStatusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #orderNo="{ row }"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '—' }}</strong></template>
<template #transport="{ row }"><span>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</span></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '—' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '—' }}</span></div></template>
<template #receivingWarehouse="{ row }"><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '—' }}</span></template>
<template #expectedShipDate="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).expectedShipDate).slice(0, 10) }}</span></template>
<template #createdAt="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></template>
<template #empty><el-empty description="当前客户暂无运单记录" /></template>
</DataTable>
</template>
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
</section>
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div>
</DetailPageLayout>
@@ -263,7 +263,7 @@ describe('CustomersView', () => {
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName)
await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative)
expect(wrapper.get('input[placeholder="请输入法定代表人姓名(选填)"]').attributes('aria-required')).not.toBe('true')
await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName)
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
@@ -277,6 +277,7 @@ describe('CustomersView', () => {
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
tenantName: fullTenant.tenantName,
contactPhone: fullTenant.contactPhone,
legalRepresentative: '',
}))
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
@@ -710,6 +711,9 @@ describe('CustomersView', () => {
auth.permissions = [...auth.permissions, 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' })
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [fullTenant], page: 1, size: 10, total: 1, pages: 1 })
.mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 })
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -730,6 +734,7 @@ describe('CustomersView', () => {
await flushPromises()
expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id)
expect(document.body.textContent).not.toContain(fullTenant.tenantName)
})
it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => {
@@ -757,24 +762,6 @@ describe('CustomersView', () => {
expect(document.body.textContent).toContain('代登录原因')
})
it('treats archived customers as read-only and terminal', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:edit', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, status: 'ARCHIVED' }], page: 1, size: 10, total: 1, pages: 1,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('已归档')
expect(tableButtonByText('编辑')).toBeUndefined()
expect(tableButtonByText('启用')).toBeUndefined()
expect(tableButtonByText('停用')).toBeUndefined()
expect(tableButtonByText('删除')).toBeUndefined()
})
it('renders one overflow menu per customer when permitted actions exceed three', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
@@ -131,7 +131,7 @@ const queryFields: QueryField[] = [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
{ label: '已停用', value: 'DISABLED' }, { label: '已归档', value: 'ARCHIVED' },
{ label: '已停用', value: 'DISABLED' },
] },
]
@@ -146,7 +146,7 @@ const form = ref(emptyForm())
const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
legalRepresentative: [{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' }],
unifiedSocialCreditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
@@ -601,7 +601,7 @@ async function saveCustomer() {
} else if (hadSelectedQualifications) {
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
} else {
ElMessage.success('客户资料已保存,尚未提交审核')
ElMessage.success('客户资料已保存')
}
await loadCustomers()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
@@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders'
import {
getActiveCustomerCount,
getDashboardOperationalMetrics,
getTodayNewOrderCount,
} from './dashboard'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() }))
vi.mock('@/shared/api/http', async (importOriginal) => ({
...await importOriginal<typeof import('@/shared/api/http')>(),
http: { get: vi.fn() },
}))
describe('dashboard metrics api', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes the operational metrics response', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: {
inTransitContainerCount: '3', inTransitTeu: '4.25', unknownContainerTypeCount: '1',
pendingCustomsOrderCount: '7', asOf: '2026-09-11T12:00:00',
} })
await expect(getDashboardOperationalMetrics()).resolves.toEqual({
inTransitContainerCount: 3, inTransitTeu: 4.25, unknownContainerTypeCount: 1,
pendingCustomsOrderCount: 7, asOf: '2026-09-11T12:00:00',
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/dashboard/operational-metrics')
})
it('uses list totals for today orders and active customers', async () => {
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 1, total: 12, pages: 12 })
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 1, total: 8, pages: 8 })
await expect(getTodayNewOrderCount()).resolves.toBe(12)
await expect(getActiveCustomerCount()).resolves.toBe(8)
expect(listPlatformUnifiedOrders).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 1 }))
const orderQuery = vi.mocked(listPlatformUnifiedOrders).mock.calls[0]![0]
expect(orderQuery.createdFrom).toMatch(/T00:00:00\.000$/)
expect(orderQuery.createdTo).toMatch(/T23:59:59\.999$/)
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 1, status: 'ACTIVE' })
})
})
+55
View File
@@ -0,0 +1,55 @@
import dayjs from 'dayjs'
import { listPlatformTenants } from '@/modules/iam'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders'
import { http, type ApiEnvelope, unwrapApiResponse } from '@/shared/api/http'
export interface DashboardOperationalMetrics {
inTransitContainerCount: number
inTransitTeu: number
unknownContainerTypeCount: number
pendingCustomsOrderCount: number
asOf: string | null
}
interface RawDashboardOperationalMetrics {
inTransitContainerCount: number | string
inTransitTeu: number | string
unknownContainerTypeCount: number | string
pendingCustomsOrderCount: number | string
asOf?: string | null
}
function numberValue(value: number | string | null | undefined) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
export async function getDashboardOperationalMetrics(): Promise<DashboardOperationalMetrics> {
const response = await http.get<ApiEnvelope<RawDashboardOperationalMetrics>, ApiEnvelope<RawDashboardOperationalMetrics>>(
'/platform/v1/first-leg/dashboard/operational-metrics',
)
const data = unwrapApiResponse(response)
return {
inTransitContainerCount: numberValue(data.inTransitContainerCount),
inTransitTeu: numberValue(data.inTransitTeu),
unknownContainerTypeCount: numberValue(data.unknownContainerTypeCount),
pendingCustomsOrderCount: numberValue(data.pendingCustomsOrderCount),
asOf: data.asOf || null,
}
}
export async function getTodayNewOrderCount() {
const today = dayjs()
const result = await listPlatformUnifiedOrders({
createdFrom: today.startOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'),
createdTo: today.endOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'),
page: 1,
size: 1,
})
return result.total
}
export async function getActiveCustomerCount() {
const result = await listPlatformTenants({ page: 1, size: 1, status: 'ACTIVE' })
return result.total
}
@@ -1,13 +1,34 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardView from './DashboardView.vue'
describe('DashboardView', () => {
it('labels placeholder values and keeps task actions non-operational', () => {
const wrapper = mount(DashboardView)
vi.mock('../api/dashboard', () => ({
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
getActiveCustomerCount: vi.fn().mockResolvedValue(8),
getDashboardOperationalMetrics: vi.fn().mockResolvedValue({
inTransitContainerCount: 3,
inTransitTeu: 4.25,
unknownContainerTypeCount: 1,
pendingCustomsOrderCount: 7,
asOf: '2026-09-11T12:00:00',
}),
}))
expect(wrapper.text()).toContain('静态演示数据')
expect(wrapper.text()).toContain('演示快照:2026-06-02 08:45')
describe('DashboardView', () => {
beforeEach(() => vi.clearAllMocks())
it('renders the four live metrics and keeps unfinished task actions non-operational', async () => {
const wrapper = mount(DashboardView)
await flushPromises()
expect(wrapper.text()).toContain('四项核心指标已接入实时业务数据')
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
expect(wrapper.text()).toContain('12 票')
expect(wrapper.text()).toContain('4.25 TEU')
expect(wrapper.text()).toContain('8 家')
expect(wrapper.text()).toContain('7 票')
expect(wrapper.text()).toContain('1 柜箱型未知')
const taskActions = wrapper.findAll('.dashboard-task-action')
expect(taskActions).toHaveLength(3)
expect(taskActions.every((button) => button.attributes('disabled') !== undefined)).toBe(true)
+65 -18
View File
@@ -1,14 +1,22 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
ArrowUpRight,
Clock3,
Expand,
Minimize,
RefreshCw,
Ship,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import {
getActiveCustomerCount,
getDashboardOperationalMetrics,
getTodayNewOrderCount,
type DashboardOperationalMetrics,
} from '../api/dashboard'
type TransportMode = 'sea' | 'air' | 'land'
@@ -22,14 +30,31 @@ interface TrendSeries {
const dashboardRef = ref<HTMLElement | null>(null)
const activeMode = ref<TransportMode>('sea')
const isFullscreen = ref(false)
const metricsLoading = ref(true)
const metricsError = ref(false)
const todayNewOrderCount = ref<number | null>(null)
const activeCustomerCount = ref<number | null>(null)
const operationalMetrics = ref<DashboardOperationalMetrics | null>(null)
const metricsUpdatedAt = ref<string | null>(null)
const metrics = [
{ label: '今日新运单', value: '128', unit: '票', note: '较昨日', change: '↑ 12.3%', tone: 'blue', warning: false },
{ label: '在途柜量', value: '3,842', unit: 'TEU', note: '较上周', change: '↑ 8.5%', tone: 'green', warning: false },
{ label: '本月营收', value: '¥856', unit: '万', note: '较上月', change: '↑ 15.2%', tone: 'orange', warning: false },
{ label: '活跃客户', value: '368', unit: '家', note: '本周新增', change: '+6 家', tone: 'purple', warning: false },
{ label: '待清关票数', value: '47', unit: '票', note: '即将超期', change: '3 票', tone: 'rose', warning: true },
] as const
function displayNumber(value: number | null | undefined, maximumFractionDigits = 0) {
if (metricsLoading.value) return '…'
if (value === null || value === undefined) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
const metrics = computed(() => [
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
{
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
note: operationalMetrics.value
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
: '已开船且尚未到仓',
tone: 'green',
},
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', tone: 'purple' },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose' },
])
const trendSeries: Record<TransportMode, TrendSeries[]> = {
sea: [
@@ -110,7 +135,28 @@ function syncFullscreenState() {
isFullscreen.value = document.fullscreenElement === dashboardRef.value
}
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreenState))
async function loadMetrics() {
metricsLoading.value = true
metricsError.value = false
const [orders, customers, operations] = await Promise.allSettled([
getTodayNewOrderCount(),
getActiveCustomerCount(),
getDashboardOperationalMetrics(),
])
todayNewOrderCount.value = orders.status === 'fulfilled' ? orders.value : null
activeCustomerCount.value = customers.status === 'fulfilled' ? customers.value : null
operationalMetrics.value = operations.status === 'fulfilled' ? operations.value : null
metricsError.value = [orders, customers, operations].some(result => result.status === 'rejected')
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value.asOf
? operations.value.asOf
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
metricsLoading.value = false
}
onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState)
void loadMetrics()
})
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFullscreenState))
</script>
@@ -119,16 +165,20 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
THESIS: 用一屏可扫描的经营驾驶舱承载平台概览,拒绝把数据拆成互不关联的装饰卡片。
OWN-WORLD: 延续 Admin 冷灰工作面、白色数据面板和柏悦蓝主色;状态色只承担分类与告警。
STORY: 运营人员先识别核心体量,再查看趋势与构成,最后定位待处理事项。
FIRST VIEWPORT: 顶部状态栏与五项核心指标占据首屏上半部,主趋势图和状态环图紧随其后。
FIRST VIEWPORT: 顶部状态栏与四项核心指标占据首屏上半部,主趋势图和状态环图紧随其后。
FORM: 既有后台视觉体系的经营驾驶舱结构,基于用户提供的正式原型直接扩展。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<section ref="dashboardRef" class="dashboard-view">
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
<p>当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试;下方图表及待办仍为静态演示数据。' : '四项核心指标已接入实时业务数据;下方图表及待办仍为静态演示数据。' }}</p>
<div class="dashboard-head-actions">
<span>演示快照:2026-06-02 08:45</span>
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
</button>
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
<Expand v-else :size="16" aria-hidden="true" />
@@ -144,7 +194,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<span class="dashboard-metric-mark" aria-hidden="true" />
<p>{{ metric.label }}</p>
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
<span>{{ metric.note }}</span>
</article>
</div>
</template>
@@ -250,20 +300,18 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
.dashboard-outline-button { padding: 0 13px; }
.dashboard-outline-button:hover { border-color: var(--cc-primary); color: var(--cc-primary); }
.dashboard-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.dashboard-metric { position: relative; min-width: 0; overflow: hidden; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
.dashboard-metric-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
.dashboard-metric.is-green .dashboard-metric-mark { background: #e8f7ee; }
.dashboard-metric.is-orange .dashboard-metric-mark { background: #fff3e3; }
.dashboard-metric.is-purple .dashboard-metric-mark { background: #f1eaff; }
.dashboard-metric.is-rose .dashboard-metric-mark { background: #fdeaf1; }
.dashboard-metric p { position: relative; margin: 0 0 25px; color: #5f6573; font-size: 13px; font-weight: 700; }
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; line-height: 1; letter-spacing: -.02em; }
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
.dashboard-metric > span b { margin-left: 4px; color: #ef4444; font-weight: 700; }
.dashboard-metric > span b.is-warning { color: #d92d20; }
.dashboard-grid { display: grid; min-width: 0; gap: 18px; }
.dashboard-grid--primary { grid-template-columns: minmax(0, 1.8fr) minmax(300px, .9fr); }
.dashboard-grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -329,7 +377,6 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
.dashboard-content { gap: 14px; }
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.dashboard-metric { min-height: 116px; padding: 13px; }
.dashboard-metric:last-child { grid-column: 1 / -1; }
.dashboard-metric-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
.dashboard-metric p { margin-bottom: 12px; font-size: 11px; }
.dashboard-metric strong { font-size: 23px; }
@@ -1,6 +1,6 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type PriceBookStatus = 'PENDING_CONFIRMATION' | 'ACTIVE' | 'SUPERSEDED' | 'FAILED'
export type PriceBookStatus = 'PENDING_CONFIRMATION' | 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' | 'FAILED'
export type PriceModel = 'FBA_WAREHOUSE_RATE' | 'REGION_WEIGHT_TIER'
export type PricingTaxMode = 'TAX_INCLUDED' | 'TAX_EXCLUDED'
export type PricingDestinationType = 'FBA' | 'OVERSEAS'
@@ -27,6 +27,9 @@ export interface PlatformPriceBookVersion {
publishedBy: string | null
publishedAt: string | null
supersededAt: string | null
voidedBy: string | null
voidedAt: string | null
voidReason: string | null
}
/** Read-only channel data parsed from the uploaded workbook. */
@@ -102,7 +105,7 @@ export interface PlatformPriceBookQuery {
}
interface PriceBookVersionPayload extends Omit<PlatformPriceBookVersion,
'id' | 'customerId' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
'id' | 'customerId' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy' | 'voidedBy'> {
id: ApiId
customerId: ApiId
versionNo: ApiNumber
@@ -113,6 +116,7 @@ interface PriceBookVersionPayload extends Omit<PlatformPriceBookVersion,
warningCount?: ApiNumber | null
createdBy?: ApiId | null
publishedBy?: ApiId | null
voidedBy?: ApiId | null
}
interface PriceBookParsedChannelPayload extends Omit<PlatformPriceBookParsedChannel,
@@ -181,6 +185,9 @@ function normalizeVersion(value: PriceBookVersionPayload): PlatformPriceBookVers
publishedBy: nullableId(value.publishedBy),
publishedAt: nullableText(value.publishedAt),
supersededAt: nullableText(value.supersededAt),
voidedBy: nullableId(value.voidedBy),
voidedAt: nullableText(value.voidedAt),
voidReason: nullableText(value.voidReason),
}
}
@@ -271,6 +278,14 @@ export async function publishPlatformPriceBook(
return normalizeDetail(unwrapApiResponse(response))
}
export async function voidPlatformPriceBook(versionId: string, reason: string): Promise<PlatformPriceBookDetail> {
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(
`${basePath}/${encodeURIComponent(versionId)}/void`,
{ reason },
)
return normalizeDetail(unwrapApiResponse(response))
}
export function downloadPlatformPriceBookSource(versionId: string) {
return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(versionId)}/source`, { responseType: 'blob' })
}
@@ -0,0 +1,596 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import {
downloadPlatformPriceBookSource,
getPlatformPriceBook,
publishPlatformPriceBook,
type PlatformPriceBookDetail,
type PlatformPriceBookParseIssue,
type PlatformPriceBookParsedRate,
type PlatformPriceBookVersion,
type PriceBookConfirmationRequest,
type PriceBookStatus,
} from '../api/platformPriceBooks'
interface OriginMappingRow {
key: string
sourceCode: string
sourceName: string
warehouseId: string | null
}
interface OverseasMappingRow {
key: string
sourceName: string
destinationCode: string | null
warehouseId: string | null
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const versionId = computed(() => String(route.params.versionId || ''))
const detail = ref<PlatformPriceBookDetail | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const selectedChannelCode = ref('')
const publishing = ref(false)
const downloading = ref(false)
const mappingWarehouses = ref<PlatformWarehouseResource[]>([])
const mappingWarehousesLoaded = ref(false)
const mappingWarehousesLoading = ref(false)
const mappingWarehousesError = ref<ApiErrorDetails | null>(null)
const customerOptions = ref<PlatformTenantResource[]>([])
const originSelections = ref<Record<string, string>>({})
const overseasSelections = ref<Record<string, string>>({})
let detailSequence = 0
let warehouseSequence = 0
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
const isPending = computed(() => detail.value?.version.status === 'PENDING_CONFIRMATION')
const domesticWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'
)))
const overseasWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS'
)))
const originMappingRows = computed<OriginMappingRow[]>(() => {
const unique = new Map<string, OriginMappingRow>()
for (const rate of detail.value?.rates || []) {
const sourceCode = rate.originWarehouseCode.trim()
if (!sourceCode) continue
const key = mappingKey(sourceCode)
if (!unique.has(key)) unique.set(key, {
key,
sourceCode,
sourceName: rate.originWarehouseName || sourceCode,
warehouseId: rate.originWarehouseId,
})
}
return [...unique.values()]
})
const overseasMappingRows = computed<OverseasMappingRow[]>(() => {
const unique = new Map<string, OverseasMappingRow>()
for (const rate of detail.value?.rates || []) {
if (rate.destinationType !== 'OVERSEAS' || !rate.destinationName?.trim()) continue
const sourceName = rate.destinationName.trim()
const key = mappingKey(sourceName)
if (!unique.has(key)) unique.set(key, {
key,
sourceName,
destinationCode: rate.destinationCode,
warehouseId: rate.destinationWarehouseId,
})
}
return [...unique.values()]
})
const hasMissingOverseasSource = computed(() => (detail.value?.rates || []).some((rate) => (
rate.destinationType === 'OVERSEAS' && !rate.destinationName?.trim()
)))
const selectedChannelRates = computed(() => {
const rates = detail.value?.rates || []
return selectedChannelCode.value ? rates.filter((rate) => rate.channelCode === selectedChannelCode.value) : rates
})
const selectedChannel = computed(() => detail.value?.channels.find((channel) => channel.channelCode === selectedChannelCode.value) || null)
const mappingBlockingIssueCodes = new Set([
'ORIGIN_MAPPING_REQUIRED',
'ORIGIN_WAREHOUSE_INVALID',
'OVERSEAS_MAPPING_REQUIRED',
'OVERSEAS_WAREHOUSE_INVALID',
'WAREHOUSE_VALIDATION_FAILED',
'DUPLICATE_ORIGIN_MAPPING',
'DUPLICATE_OVERSEAS_MAPPING',
'UNUSED_ORIGIN_MAPPING',
'UNUSED_OVERSEAS_MAPPING',
'DUPLICATE_WAREHOUSE_MAPPING',
'DUPLICATE_RATE',
])
const nonMappingBlockingIssues = computed(() => (detail.value?.issues || []).filter((issue) => (
issue.severity === 'BLOCKING' && !mappingBlockingIssueCodes.has(issue.issueCode)
)))
const mappingsComplete = computed(() => (
originMappingRows.value.length > 0
&& originMappingRows.value.every((row) => Boolean(originSelections.value[row.key]))
&& overseasMappingRows.value.every((row) => Boolean(overseasSelections.value[row.key]))
))
const mappingCandidatesMissing = computed(() => (
originMappingRows.value.some((row) => !domesticWarehouses.value.some((warehouse) => originWarehouseMatches(row, warehouse)))
|| overseasMappingRows.value.some((row) => !overseasWarehouses.value.some((warehouse) => overseasWarehouseMatches(row, warehouse)))
))
const canConfirm = computed(() => (
isPending.value
&& canEdit.value
&& !loading.value
&& !publishing.value
&& !mappingWarehousesLoading.value
&& mappingWarehousesLoaded.value
&& !mappingWarehousesError.value
&& !hasMissingOverseasSource.value
&& !mappingCandidatesMissing.value
&& !nonMappingBlockingIssues.value.length
&& mappingsComplete.value
))
const confirmationHint = computed(() => {
if (!isPending.value) return ''
if (nonMappingBlockingIssues.value.length) return '当前文件仍有无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。'
if (hasMissingOverseasSource.value) return '存在未识别名称的海外仓价格行,无法完成映射。'
if (mappingWarehousesError.value) return '平台仓库候选加载失败,暂不能确认生效。'
if (mappingWarehousesLoading.value || !mappingWarehousesLoaded.value) return '正在加载平台仓库候选。'
if (mappingCandidatesMissing.value) return '上传表中的仓库编码尚未配置为匹配的启用平台公共仓库。'
if (!mappingsComplete.value) return '请完成所有起运仓和海外仓映射。'
return '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
})
function mappingKey(value: string) {
return value.trim().toUpperCase()
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatEffectivePeriod(version: PlatformPriceBookVersion) {
if (!version.publishedAt) return '尚未生效'
const start = formatDateTime(version.publishedAt)
return version.supersededAt ? `${start} 至 ${formatDateTime(version.supersededAt)}` : `${start} 起`
}
function priceBookScopeLabel(customerId: string | null | undefined) {
if (!customerId || customerId === '0') return '平台公共报价'
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
return customer ? `客户专属 · ${customer.tenantName || '未命名客户'}` : '客户专属报价'
}
function formatMoney(value: number, currency: string) {
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
function priceBookStatusMeta(status: PriceBookStatus) {
return {
PENDING_CONFIRMATION: { label: '待确认', type: 'warning' as const },
ACTIVE: { label: '生效中', type: 'success' as const },
SUPERSEDED: { label: '已替换', type: 'info' as const },
VOIDED: { label: '已作废', type: 'info' as const },
FAILED: { label: '解析失败', type: 'danger' as const },
}[status]
}
function priceModelLabel(value: string) {
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
}
function pricingTaxModeLabel(value: string | null) {
if (value === 'TAX_INCLUDED') return '包税'
if (value === 'TAX_EXCLUDED') return '不包税'
return '—'
}
function rateDestinationLabel(rate: PlatformPriceBookParsedRate) {
if (rate.destinationType === 'OVERSEAS') return rate.destinationName || rate.destinationCode || '—'
if (rate.destinationType === 'FBA') return rate.destinationCode || rate.destinationName || '—'
return rate.regionCode || '—'
}
function issueLocation(issue: PlatformPriceBookParseIssue) {
return [issue.sourceSheetName, issue.sourceRowNumber ? `第 ${issue.sourceRowNumber} 行` : null, issue.sourceCell]
.filter(Boolean)
.join(' · ') || '—'
}
function sourceLocation(rate: PlatformPriceBookParsedRate) {
return `${rate.sourceSheetName} · 第 ${rate.sourceRowNumber} 行`
}
function apiErrorMessage(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
}
function safeFileName(value: string) {
const safe = value.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim()
return safe || '船期价格表.xlsx'
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try {
anchor.href = url
anchor.download = safeFileName(fileName)
document.body.append(anchor)
anchor.click()
} finally {
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
}
function resetMappings(value: PlatformPriceBookDetail) {
const origins: Record<string, string> = {}
const overseas: Record<string, string> = {}
for (const rate of value.rates) {
const originCode = rate.originWarehouseCode.trim()
if (originCode && rate.originWarehouseId) origins[mappingKey(originCode)] = rate.originWarehouseId
if (rate.destinationType === 'OVERSEAS' && rate.destinationName?.trim() && rate.destinationWarehouseId) {
overseas[mappingKey(rate.destinationName)] = rate.destinationWarehouseId
}
}
originSelections.value = origins
overseasSelections.value = overseas
}
function autoSelectExactWarehouseMappings() {
const origins = { ...originSelections.value }
const overseas = { ...overseasSelections.value }
const usedOrigins = new Set(Object.values(origins))
const usedOverseas = new Set(Object.values(overseas))
for (const row of originMappingRows.value) {
if (origins[row.key]) continue
const matches = domesticWarehouses.value.filter((warehouse) => originWarehouseMatches(row, warehouse))
if (matches.length !== 1) continue
const warehouseId = String(matches[0].id)
if (!usedOrigins.has(warehouseId)) {
origins[row.key] = warehouseId
usedOrigins.add(warehouseId)
}
}
for (const row of overseasMappingRows.value) {
if (overseas[row.key]) continue
const matches = overseasWarehouses.value.filter((warehouse) => overseasWarehouseMatches(row, warehouse))
if (matches.length !== 1) continue
const warehouseId = String(matches[0].id)
if (!usedOverseas.has(warehouseId)) {
overseas[row.key] = warehouseId
usedOverseas.add(warehouseId)
}
}
originSelections.value = origins
overseasSelections.value = overseas
}
function applyDetail(value: PlatformPriceBookDetail) {
detail.value = value
selectedChannelCode.value = value.channels[0]?.channelCode || ''
resetMappings(value)
if (mappingWarehousesLoaded.value) autoSelectExactWarehouseMappings()
}
async function loadCustomerOptions() {
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customerOptions.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customerOptions.value = []
}
}
async function loadMappingWarehouses(force = false) {
if (mappingWarehousesLoaded.value && !force) return true
const sequence = ++warehouseSequence
mappingWarehousesLoading.value = true
mappingWarehousesError.value = null
try {
const result = await listAllPlatformWarehouses()
if (sequence !== warehouseSequence) return false
mappingWarehouses.value = result
mappingWarehousesLoaded.value = true
autoSelectExactWarehouseMappings()
return true
} catch (error) {
if (sequence === warehouseSequence) {
mappingWarehouses.value = []
mappingWarehousesLoaded.value = false
mappingWarehousesError.value = getApiErrorDetails(error, '平台公共仓库加载失败,请稍后重试')
}
return false
} finally {
if (sequence === warehouseSequence) mappingWarehousesLoading.value = false
}
}
async function loadDetail() {
const id = versionId.value
const sequence = ++detailSequence
detail.value = null
loadError.value = null
if (!id) {
loadError.value = { message: '缺少价格表版本,无法加载详情' }
return
}
loading.value = true
try {
const result = await getPlatformPriceBook(id)
if (sequence !== detailSequence) return
applyDetail(result)
if (result.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
} catch (error) {
if (sequence === detailSequence) loadError.value = getApiErrorDetails(error, '船期价格表详情加载失败,请稍后重试')
} finally {
if (sequence === detailSequence) loading.value = false
}
}
function originWarehouseMatches(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
return mappingKey(warehouse.warehouseCode) === mappingKey(row.sourceCode)
}
function overseasWarehouseMatches(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
return !row.destinationCode || mappingKey(warehouse.warehouseCode) === mappingKey(row.destinationCode)
}
function selectedByOther(selections: Record<string, string>, currentKey: string, warehouseId: string) {
return Object.entries(selections).some(([key, selected]) => key !== currentKey && selected === warehouseId)
}
function originWarehouseDisabled(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
return !originWarehouseMatches(row, warehouse)
|| selectedByOther(originSelections.value, row.key, String(warehouse.id))
}
function overseasWarehouseDisabled(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
return !overseasWarehouseMatches(row, warehouse)
|| selectedByOther(overseasSelections.value, row.key, String(warehouse.id))
}
function buildConfirmationRequest(): PriceBookConfirmationRequest {
return {
originMappings: originMappingRows.value.map((row) => ({
originWarehouseCode: row.sourceCode,
warehouseId: originSelections.value[row.key],
})),
overseasMappings: overseasMappingRows.value.map((row) => ({
sourceDestinationName: row.sourceName,
warehouseId: overseasSelections.value[row.key],
})),
}
}
async function confirmPriceBook() {
if (!detail.value || !canConfirm.value) return
try {
await ElMessageBox.confirm(
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error('确认操作未完成,请重试。')
return
}
publishing.value = true
try {
applyDetail(await publishPlatformPriceBook(detail.value.version.id, buildConfirmationRequest()))
ElMessage.success('船期价格表已生效,上一生效版本已保留为历史版本。')
} catch (error) {
ElMessage.error(apiErrorMessage(error, '确认生效失败,请根据校验结果修正后重试'))
await loadDetail()
} finally {
publishing.value = false
}
}
async function downloadSource() {
if (!detail.value) return
downloading.value = true
try {
const blob = await downloadPlatformPriceBookSource(detail.value.version.id)
if (!blob.size) throw new Error('原始价格表内容为空')
saveBlob(blob, detail.value.version.originalFileName)
} catch (error) {
ElMessage.error(apiErrorMessage(error, '原始价格表下载失败,请稍后重试'))
} finally {
downloading.value = false
}
}
function backToList() {
void router.push({ name: 'fba-carrier-rates' })
}
watch(versionId, () => { void loadDetail() })
onMounted(() => {
void loadCustomerOptions()
void loadDetail()
})
</script>
<template>
<AdminPage as="div" class="price-book-detail" spacing="normal">
<DetailPageLayout aria-label="船期价格表版本详情">
<template #header>
<div class="detail-heading">
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar
class="detail-toolbar"
as="section"
:title="detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
:description="detail ? `${priceBookScopeLabel(detail.version.customerId)} · 上传于 ${formatDateTime(detail.version.createdAt)}` : '查看版本、仓库映射和渠道价格'"
>
<template v-if="detail" #actions>
<el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag>
<el-button :loading="downloading" @click="downloadSource"><Download :size="16" />下载原文件</el-button>
<el-button v-if="isPending && canEdit" type="primary" :loading="publishing" :disabled="!canConfirm" @click="confirmPriceBook"><CircleCheck :size="16" />确认并立即生效</el-button>
</template>
</PageToolbar>
</div>
</template>
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else-if="detail">
<section class="surface-panel version-panel">
<header class="section-heading">
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
</header>
<dl class="detail-grid">
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
<div class="detail-grid__wide"><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
<div v-if="detail.version.status === 'VOIDED'" class="detail-grid__wide"><dt>作废原因</dt><dd>{{ detail.version.voidReason || '—' }}</dd></div>
</dl>
</section>
<section v-if="isPending && canEdit" class="surface-panel mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射</h2><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
type="error"
:closable="false"
show-icon
/>
<el-alert v-else-if="confirmationHint" :title="confirmationHint" :type="canConfirm ? 'success' : 'warning'" :closable="false" show-icon />
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)"><RefreshCw :size="16" />重新加载</el-button>
</div>
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
<section class="mapping-group">
<h3>起运仓映射</h3>
<div v-for="row in originMappingRows" :key="row.key" class="mapping-row">
<div><strong>{{ row.sourceName }}</strong><span>来源编码:{{ row.sourceCode }}</span></div>
<el-select v-model="originSelections[row.key]" filterable placeholder="请选择匹配的国内仓">
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" :disabled="originWarehouseDisabled(row, warehouse)" />
</el-select>
</div>
</section>
<section v-if="overseasMappingRows.length" class="mapping-group">
<h3>海外仓映射</h3>
<div v-for="row in overseasMappingRows" :key="row.key" class="mapping-row">
<div><strong>{{ row.sourceName }}</strong><span>目的仓编码:{{ row.destinationCode || '未提供' }}</span></div>
<el-select v-model="overseasSelections[row.key]" filterable placeholder="请选择匹配的海外仓">
<el-option v-for="warehouse in overseasWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" :disabled="overseasWarehouseDisabled(row, warehouse)" />
</el-select>
</div>
</section>
<el-alert v-if="mappingCandidatesMissing" title="未找到编码匹配的启用平台公共仓库,请先在仓库管理中补齐对应仓库。" type="warning" :closable="false" show-icon />
<el-alert v-if="hasMissingOverseasSource" title="有海外仓价格行缺少来源名称,无法确认映射。" type="error" :closable="false" show-icon />
</div>
</section>
<section class="surface-panel detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验</h2><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table>
</section>
<section class="surface-panel detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
</el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
</section>
</template>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; }
.detail-heading { display: flex; min-width: 0; align-items: flex-start; gap: 16px; }
.detail-toolbar { min-width: 0; flex: 1; }
.detail-toolbar :deep(.page-toolbar__actions) { align-items: center; }
.surface-panel { padding: 16px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-heading h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 15px; }
.section-heading h2 svg { color: var(--cc-primary); }
.section-heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.detail-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.detail-grid > div { min-width: 0; padding: 11px 13px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.detail-grid > div:nth-child(5n) { border-right: 0; }
.detail-grid__wide { grid-column: span 3; border-top: 1px solid var(--cc-border); }
.detail-grid__wide + .detail-grid__wide { grid-column: span 2; }
.detail-grid dt { color: var(--cc-muted); font-size: 11px; }
.detail-grid dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; font-weight: 700; }
.is-danger { color: var(--el-color-danger) !important; }
.mapping-section, .detail-section { display: grid; min-width: 0; gap: 14px; }
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
.mapping-groups { display: grid; gap: 14px; }
.mapping-group { display: grid; gap: 8px; }
.mapping-group h3 { margin: 0; color: var(--cc-text); font-size: 13px; }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: 16px; padding: 11px 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
.mapping-row > div { display: grid; min-width: 0; gap: 4px; }
.mapping-row strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; }
.mapping-row span { color: var(--cc-muted); font-size: 12px; }
.mapping-row :deep(.el-select) { width: 100%; }
.mapping-load-error { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; }
.mapping-load-error strong { color: var(--cc-danger); font-size: 13px; }
.mapping-load-error p { margin: 4px 0; color: var(--cc-text); font-size: 12px; }
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
.channel-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) {
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-toolbar { width: 100%; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-grid > div, .detail-grid > div:nth-child(5n) { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-grid > div:nth-child(2n) { border-right: 0; }
.detail-grid__wide, .detail-grid__wide + .detail-grid__wide { grid-column: span 2; }
.mapping-row { grid-template-columns: 1fr; gap: 10px; }
}
</style>
@@ -3,6 +3,8 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8')
const detailSource = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'), 'utf8')
const routeSource = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/routes.ts'), 'utf8')
const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
describe('CarrierRatesView price-book source contract', () => {
@@ -19,13 +21,14 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('uses only the parsed price-book workflow instead of legacy carrier-rate editing', () => {
const workflowSource = `${source}\n${detailSource}`
for (const capability of [
'listPlatformPriceBooks',
'getPlatformPriceBook',
'uploadPlatformPriceBook',
'publishPlatformPriceBook',
'downloadPlatformPriceBookSource',
]) expect(source).toContain(capability)
]) expect(workflowSource).toContain(capability)
for (const legacyCapability of [
'createPlatformCarrierRateVersion',
@@ -35,43 +38,47 @@ describe('CarrierRatesView price-book source contract', () => {
'编辑价格',
'有效期',
'生效日期',
]) expect(source).not.toContain(legacyCapability)
]) expect(workflowSource).not.toContain(legacyCapability)
})
it('keeps uploaded prices immutable and makes confirmation the only edit action', () => {
it('opens a formal detail route and keeps uploaded prices immutable', () => {
expect(source).toContain('上传并解析')
expect(source).toContain('查看并确认')
expect(source).toContain('确认并立即生效')
expect(source).toContain('价格行不可在页面内修改')
expect(source).toContain('confirmPriceBook')
expect(source).toContain('publishPlatformPriceBook')
expect(source).not.toContain('草稿')
expect(source).toContain("name: 'fba-carrier-rate-detail'")
expect(routeSource).toContain("path: 'first-leg-pricing/carrier-rates/:versionId'")
expect(routeSource).toContain("import('./carrier-rates/pages/CarrierRateDetailView.vue')")
expect(detailSource).toContain('返回列表')
expect(detailSource).toContain('确认并立即生效')
expect(detailSource).toContain('价格行不可在页面内修改')
expect(detailSource).toContain('confirmPriceBook')
expect(detailSource).toContain('publishPlatformPriceBook')
expect(`${source}\n${detailSource}`).not.toContain('草稿')
})
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
expect(source).toContain(`label="${label}"`)
expect(detailSource).toContain(`label="${label}"`)
}
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(source).toContain(`selectedChannel?.${field}`)
expect(detailSource).toContain(`selectedChannel?.${field}`)
}
expect(source).not.toContain('AMS+换单费')
expect(source).not.toContain('建议报价')
expect(detailSource).not.toContain('AMS+换单费')
expect(detailSource).not.toContain('建议报价')
})
it('maps only active platform public domestic and overseas warehouses before confirmation', () => {
expect(source).toContain('listAllPlatformWarehouses()')
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain("warehouse.warehouseType === 'OVERSEAS'")
expect(source).toContain('起运仓映射')
expect(source).toContain('海外仓映射')
expect(source).toContain('originWarehouseCode')
expect(source).toContain('sourceDestinationName')
expect(source).toContain('autoSelectExactWarehouseMappings')
expect(source).toContain("'DUPLICATE_RATE'")
expect(source).toContain('.channel-tabs { width: 100%; min-width: 0;')
expect(source).toContain('.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }')
expect(source).not.toContain('tenantId')
expect(detailSource).toContain('listAllPlatformWarehouses()')
expect(detailSource).toContain("warehouse.warehouseType === 'DOMESTIC'")
expect(detailSource).toContain("warehouse.warehouseType === 'OVERSEAS'")
expect(detailSource).toContain('起运仓映射')
expect(detailSource).toContain('海外仓映射')
expect(detailSource).toContain('originWarehouseCode')
expect(detailSource).toContain('sourceDestinationName')
expect(detailSource).toContain('autoSelectExactWarehouseMappings')
expect(detailSource).toContain("'DUPLICATE_RATE'")
expect(detailSource).toContain('.channel-tabs { width: 100%; min-width: 0;')
expect(detailSource).toContain('.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }')
expect(detailSource).not.toContain('tenantId')
})
it('supports public and customer-specific price-book scopes without exposing technical ids', () => {
@@ -81,19 +88,19 @@ describe('CarrierRatesView price-book source contract', () => {
expect(source).toContain('客户专属')
expect(source).toContain('uploadPlatformPriceBook(file, uploadCustomerId.value)')
expect(source).toContain('只替换同一报价范围')
expect(source).not.toContain('{{ detail.version.customerId }}')
expect(detailSource).not.toContain('{{ detail.version.customerId }}')
})
it('shows parsing blockers and a channel-tabbed, read-only preview', () => {
expect(source).toContain('nonMappingBlockingIssues')
expect(source).toContain('解析与校验')
expect(source).toContain('按渠道预览')
expect(source).toContain('<el-tabs v-model="selectedChannelCode"')
expect(source).toContain('sourceLocation')
expect(source).toContain('pricingTaxModeLabel')
expect(source).toContain('<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">')
expect(source).toContain('<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">')
expect(source).not.toContain('detail.issues" size="small" max-height="280" border stripe')
expect(detailSource).toContain('nonMappingBlockingIssues')
expect(detailSource).toContain('解析与校验')
expect(detailSource).toContain('按渠道预览')
expect(detailSource).toContain('<el-tabs v-model="selectedChannelCode"')
expect(detailSource).toContain('sourceLocation')
expect(detailSource).toContain('pricingTaxModeLabel')
expect(detailSource).toContain('<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">')
expect(detailSource).toContain('<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">')
expect(detailSource).not.toContain('detail.issues" size="small" max-height="280" border stripe')
})
it('accepts only xlsx uploads and retains original-file download', () => {
@@ -111,20 +118,20 @@ describe('CarrierRatesView price-book source contract', () => {
expect(queryToolbarSource).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.table-actions :deep(.el-button) { min-height: 40px; }')
expect(source).toContain('@media (max-width: 420px)')
expect(source).toContain('.detail-grid { grid-template-columns: 1fr; }')
expect(detailSource).toContain('@media (max-width: 720px)')
expect(detailSource).toContain('.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }')
})
it('uses bordered striped tables and explicit semantic business buttons', () => {
expect(source).toContain('<DataTable')
expect(source).toContain(':columns="columns"')
expect(source).toContain('border stripe aria-label="渠道价格行预览"')
expect(detailSource).toContain('border stripe aria-label="渠道价格行预览"')
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
expect(source).toMatch(/size="small"\r?\n\s+plain\r?\n\s+:loading="downloadingIds\.has/)
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<CircleCheck :size="16" />确认并立即生效')
expect(source).toContain('.price-book-footer :deep(.el-button) { min-height: 44px; }')
expect(detailSource).toContain('<CircleCheck :size="16" />确认并立即生效')
expect(detailSource).toContain("import PageToolbar from '@/shared/components/PageToolbar.vue'")
expect(source).not.toContain('<el-button link type="primary" @click="openDetail')
})
@@ -143,9 +150,10 @@ describe('CarrierRatesView price-book source contract', () => {
expect(source).not.toContain('<template #state>')
})
it('keeps both row actions directly visible without a premature more menu', () => {
it('keeps all row actions directly visible without a premature more menu', () => {
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<CircleX :size="15" />作废')
expect(source).not.toContain('MoreHorizontal')
})
})
@@ -1,31 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, CircleCheck, Download, Eye, FileSpreadsheet, FileUp, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { CircleX, Download, Eye, FileSpreadsheet, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
downloadPlatformPriceBookSource,
getPlatformPriceBook,
listPlatformPriceBooks,
publishPlatformPriceBook,
uploadPlatformPriceBook,
type PlatformPriceBookDetail,
type PlatformPriceBookParseIssue,
type PlatformPriceBookParsedRate,
voidPlatformPriceBook,
type PlatformPriceBookVersion,
type PriceBookConfirmationRequest,
type PriceBookStatus,
} from '../api/platformPriceBooks'
@@ -34,21 +27,8 @@ type PriceBookFilters = QueryModel & {
status: PriceBookStatus | ''
}
interface OriginMappingRow {
key: string
sourceCode: string
sourceName: string
warehouseId: string | null
}
interface OverseasMappingRow {
key: string
sourceName: string
destinationCode: string | null
warehouseId: string | null
}
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<PlatformPriceBookVersion[]>([])
const page = ref(1)
const pageSize = ref(20)
@@ -60,27 +40,13 @@ const uploadFileError = ref('')
const uploadCustomerId = ref('0')
const uploadInput = ref<HTMLInputElement>()
const uploading = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<PlatformPriceBookDetail | null>(null)
const selectedVersionId = ref('')
const selectedChannelCode = ref('')
const publishing = ref(false)
const downloadingIds = ref(new Set<string>())
const mappingWarehouses = ref<PlatformWarehouseResource[]>([])
const mappingWarehousesLoaded = ref(false)
const mappingWarehousesLoading = ref(false)
const mappingWarehousesError = ref<ApiErrorDetails | null>(null)
const voidingIds = ref(new Set<string>())
const customerOptions = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const originSelections = ref<Record<string, string>>({})
const overseasSelections = ref<Record<string, string>>({})
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
let loadSequence = 0
let detailSequence = 0
let warehouseSequence = 0
const queryFields = computed<QueryField[]>(() => [
{
@@ -106,13 +72,14 @@ const queryFields = computed<QueryField[]>(() => [
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
{ label: '生效中', value: 'ACTIVE' },
{ label: '已替换', value: 'SUPERSEDED' },
{ label: '已作废', value: 'VOIDED' },
{ label: '解析失败', value: 'FAILED' },
],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
@@ -127,109 +94,7 @@ const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
const pendingCount = computed(() => rows.value.filter((row) => row.status === 'PENDING_CONFIRMATION').length)
const selectedScopeLabel = computed(() => priceBookScopeLabel(appliedFilters.value.customerId))
const loadErrorMessage = computed(() => loadError.value?.message || '')
const isPendingDetail = computed(() => detail.value?.version.status === 'PENDING_CONFIRMATION')
const domesticWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'
)))
const overseasWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS'
)))
const originMappingRows = computed<OriginMappingRow[]>(() => {
const unique = new Map<string, OriginMappingRow>()
for (const rate of detail.value?.rates || []) {
const sourceCode = rate.originWarehouseCode.trim()
if (!sourceCode) continue
const key = mappingKey(sourceCode)
if (!unique.has(key)) {
unique.set(key, {
key,
sourceCode,
sourceName: rate.originWarehouseName || sourceCode,
warehouseId: rate.originWarehouseId,
})
}
}
return [...unique.values()]
})
const overseasMappingRows = computed<OverseasMappingRow[]>(() => {
const unique = new Map<string, OverseasMappingRow>()
for (const rate of detail.value?.rates || []) {
if (rate.destinationType !== 'OVERSEAS' || !rate.destinationName?.trim()) continue
const sourceName = rate.destinationName.trim()
const key = mappingKey(sourceName)
if (!unique.has(key)) {
unique.set(key, {
key,
sourceName,
destinationCode: rate.destinationCode,
warehouseId: rate.destinationWarehouseId,
})
}
}
return [...unique.values()]
})
const hasMissingOverseasSource = computed(() => (detail.value?.rates || []).some((rate) => (
rate.destinationType === 'OVERSEAS' && !rate.destinationName?.trim()
)))
const selectedChannelRates = computed(() => {
const rates = detail.value?.rates || []
return selectedChannelCode.value ? rates.filter((rate) => rate.channelCode === selectedChannelCode.value) : rates
})
const selectedChannel = computed(() => detail.value?.channels.find((channel) => channel.channelCode === selectedChannelCode.value) || null)
const mappingBlockingIssueCodes = new Set([
'ORIGIN_MAPPING_REQUIRED',
'ORIGIN_WAREHOUSE_INVALID',
'OVERSEAS_MAPPING_REQUIRED',
'OVERSEAS_WAREHOUSE_INVALID',
'WAREHOUSE_VALIDATION_FAILED',
'DUPLICATE_ORIGIN_MAPPING',
'DUPLICATE_OVERSEAS_MAPPING',
'UNUSED_ORIGIN_MAPPING',
'UNUSED_OVERSEAS_MAPPING',
'DUPLICATE_WAREHOUSE_MAPPING',
'DUPLICATE_RATE',
])
const nonMappingBlockingIssues = computed(() => (detail.value?.issues || []).filter((issue) => (
issue.severity === 'BLOCKING' && !mappingBlockingIssueCodes.has(issue.issueCode)
)))
const mappingsComplete = computed(() => (
originMappingRows.value.length > 0
&& originMappingRows.value.every((row) => Boolean(originSelections.value[row.key]))
&& overseasMappingRows.value.every((row) => Boolean(overseasSelections.value[row.key]))
))
const mappingCandidatesMissing = computed(() => (
originMappingRows.value.some((row) => !domesticWarehouses.value.some((warehouse) => originWarehouseMatches(row, warehouse)))
|| overseasMappingRows.value.some((row) => !overseasWarehouses.value.some((warehouse) => overseasWarehouseMatches(row, warehouse)))
))
const canConfirmDetail = computed(() => (
isPendingDetail.value
&& canEdit.value
&& !detailLoading.value
&& !publishing.value
&& !mappingWarehousesLoading.value
&& mappingWarehousesLoaded.value
&& !mappingWarehousesError.value
&& !hasMissingOverseasSource.value
&& !mappingCandidatesMissing.value
&& !nonMappingBlockingIssues.value.length
&& mappingsComplete.value
))
const confirmationHint = computed(() => {
if (!isPendingDetail.value) return ''
if (nonMappingBlockingIssues.value.length) return '当前文件仍有无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。'
if (hasMissingOverseasSource.value) return '存在未识别名称的海外仓价格行,无法完成映射。'
if (mappingWarehousesError.value) return '平台仓库候选加载失败,暂不能确认生效。'
if (mappingWarehousesLoading.value || !mappingWarehousesLoaded.value) return '正在加载平台仓库候选。'
if (mappingCandidatesMissing.value) return '上传表中的仓库编码尚未配置为匹配的启用平台公共仓库。'
if (!mappingsComplete.value) return '请完成所有起运仓和海外仓映射。'
return '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
})
function mappingKey(value: string) {
return value.trim().toUpperCase()
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '-'
@@ -260,46 +125,17 @@ async function loadCustomerOptions() {
}
}
function formatMoney(value: number, currency: string) {
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
function priceBookStatusMeta(status: PriceBookStatus) {
const values = {
PENDING_CONFIRMATION: { label: '待确认', type: 'warning' as const },
ACTIVE: { label: '生效中', type: 'success' as const },
SUPERSEDED: { label: '已替换', type: 'info' as const },
VOIDED: { label: '已作废', type: 'info' as const },
FAILED: { label: '解析失败', type: 'danger' as const },
}
return values[status]
}
function priceModelLabel(value: string) {
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
}
function pricingTaxModeLabel(value: string | null) {
if (value === 'TAX_INCLUDED') return '包税'
if (value === 'TAX_EXCLUDED') return '不包税'
return '-'
}
function rateDestinationLabel(rate: PlatformPriceBookParsedRate) {
if (rate.destinationType === 'OVERSEAS') return rate.destinationName || rate.destinationCode || '-'
if (rate.destinationType === 'FBA') return rate.destinationCode || rate.destinationName || '-'
return rate.regionCode || '-'
}
function issueLocation(issue: PlatformPriceBookParseIssue) {
return [issue.sourceSheetName, issue.sourceRowNumber ? `第 ${issue.sourceRowNumber} 行` : null, issue.sourceCell]
.filter(Boolean)
.join(' · ') || '-'
}
function sourceLocation(rate: PlatformPriceBookParsedRate) {
return `${rate.sourceSheetName} · 第 ${rate.sourceRowNumber} 行`
}
function apiErrorMessage(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
@@ -381,134 +217,8 @@ function selectUploadFile(event: Event) {
uploadFile.value = file
}
function resetMappings(value: PlatformPriceBookDetail) {
const origins: Record<string, string> = {}
const overseas: Record<string, string> = {}
const originByKey = new Map<string, OriginMappingRow>()
const overseasByKey = new Map<string, OverseasMappingRow>()
for (const rate of value.rates) {
const originCode = rate.originWarehouseCode.trim()
if (originCode) {
const key = mappingKey(originCode)
if (!originByKey.has(key)) originByKey.set(key, {
key,
sourceCode: originCode,
sourceName: rate.originWarehouseName || originCode,
warehouseId: rate.originWarehouseId,
})
}
if (rate.destinationType === 'OVERSEAS' && rate.destinationName?.trim()) {
const sourceName = rate.destinationName.trim()
const key = mappingKey(sourceName)
if (!overseasByKey.has(key)) overseasByKey.set(key, {
key,
sourceName,
destinationCode: rate.destinationCode,
warehouseId: rate.destinationWarehouseId,
})
}
}
for (const row of originByKey.values()) {
if (row.warehouseId) origins[row.key] = row.warehouseId
}
for (const row of overseasByKey.values()) {
if (row.warehouseId) overseas[row.key] = row.warehouseId
}
originSelections.value = origins
overseasSelections.value = overseas
}
function autoSelectExactWarehouseMappings() {
const origins = { ...originSelections.value }
const overseas = { ...overseasSelections.value }
const usedOrigins = new Set(Object.values(origins))
const usedOverseas = new Set(Object.values(overseas))
for (const row of originMappingRows.value) {
if (origins[row.key]) continue
const matches = domesticWarehouses.value.filter((warehouse) => originWarehouseMatches(row, warehouse))
if (matches.length !== 1) continue
const warehouseId = String(matches[0].id)
if (!usedOrigins.has(warehouseId)) {
origins[row.key] = warehouseId
usedOrigins.add(warehouseId)
}
}
for (const row of overseasMappingRows.value) {
if (overseas[row.key]) continue
const matches = overseasWarehouses.value.filter((warehouse) => overseasWarehouseMatches(row, warehouse))
if (matches.length !== 1) continue
const warehouseId = String(matches[0].id)
if (!usedOverseas.has(warehouseId)) {
overseas[row.key] = warehouseId
usedOverseas.add(warehouseId)
}
}
originSelections.value = origins
overseasSelections.value = overseas
}
function applyDetail(value: PlatformPriceBookDetail) {
detail.value = value
selectedVersionId.value = value.version.id
selectedChannelCode.value = value.channels[0]?.channelCode || ''
resetMappings(value)
if (mappingWarehousesLoaded.value) autoSelectExactWarehouseMappings()
}
async function loadMappingWarehouses(force = false) {
if (mappingWarehousesLoaded.value && !force) return true
const sequence = ++warehouseSequence
mappingWarehousesLoading.value = true
mappingWarehousesError.value = null
try {
const result = await listAllPlatformWarehouses()
if (sequence !== warehouseSequence) return false
mappingWarehouses.value = result
mappingWarehousesLoaded.value = true
autoSelectExactWarehouseMappings()
return true
} catch (error) {
if (sequence === warehouseSequence) {
mappingWarehouses.value = []
mappingWarehousesLoaded.value = false
mappingWarehousesError.value = getApiErrorDetails(error, '平台公共仓库加载失败,请稍后重试')
}
return false
} finally {
if (sequence === warehouseSequence) mappingWarehousesLoading.value = false
}
}
async function openDetail(row: PlatformPriceBookVersion) {
const sequence = ++detailSequence
selectedVersionId.value = row.id
detailOpen.value = true
detailLoading.value = true
detailError.value = null
detail.value = null
try {
const result = await getPlatformPriceBook(row.id)
if (sequence !== detailSequence) return
applyDetail(result)
if (result.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
} catch (error) {
if (sequence !== detailSequence) return
detailError.value = getApiErrorDetails(error, '船期价格表详情加载失败,请稍后重试')
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
async function showUploadedDetail(value: PlatformPriceBookDetail) {
detailSequence += 1
detailOpen.value = true
detailLoading.value = false
detailError.value = null
applyDetail(value)
if (value.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
function openDetail(row: PlatformPriceBookVersion) {
void router.push({ name: 'fba-carrier-rate-detail', params: { versionId: row.id } })
}
async function submitUpload() {
@@ -522,9 +232,9 @@ async function submitUpload() {
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
uploadOpen.value = false
resetUpload()
await showUploadedDetail(result)
ElMessage.success('价格表已解析,请核对结果并确认仓库映射。')
await loadPriceBooks()
ElMessage.success('价格表已解析,请在详情页核对结果并确认仓库映射。')
await router.push({ name: 'fba-carrier-rate-detail', params: { versionId: result.version.id } })
} catch (error) {
ElMessage.error(apiErrorMessage(error, '价格表上传或解析失败,请检查文件后重试'))
await loadPriceBooks()
@@ -533,69 +243,6 @@ async function submitUpload() {
}
}
function originWarehouseMatches(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
return mappingKey(warehouse.warehouseCode) === mappingKey(row.sourceCode)
}
function overseasWarehouseMatches(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
return !row.destinationCode || mappingKey(warehouse.warehouseCode) === mappingKey(row.destinationCode)
}
function selectedByOther(selections: Record<string, string>, currentKey: string, warehouseId: string) {
return Object.entries(selections).some(([key, selected]) => key !== currentKey && selected === warehouseId)
}
function originWarehouseDisabled(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
return !originWarehouseMatches(row, warehouse)
|| selectedByOther(originSelections.value, row.key, String(warehouse.id))
}
function overseasWarehouseDisabled(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
return !overseasWarehouseMatches(row, warehouse)
|| selectedByOther(overseasSelections.value, row.key, String(warehouse.id))
}
function buildConfirmationRequest(): PriceBookConfirmationRequest {
return {
originMappings: originMappingRows.value.map((row) => ({
originWarehouseCode: row.sourceCode,
warehouseId: originSelections.value[row.key],
})),
overseasMappings: overseasMappingRows.value.map((row) => ({
sourceDestinationName: row.sourceName,
warehouseId: overseasSelections.value[row.key],
})),
}
}
async function confirmPriceBook() {
if (!detail.value || !canConfirmDetail.value) return
try {
await ElMessageBox.confirm(
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error('确认操作未完成,请重试。')
return
}
publishing.value = true
try {
const result = await publishPlatformPriceBook(detail.value.version.id, buildConfirmationRequest())
applyDetail(result)
ElMessage.success('船期价格表已生效,上一生效版本已保留为历史版本。')
await loadPriceBooks()
} catch (error) {
ElMessage.error(apiErrorMessage(error, '确认生效失败,请根据校验结果修正后重试'))
if (selectedVersionId.value) await openDetail({ id: selectedVersionId.value } as PlatformPriceBookVersion)
} finally {
publishing.value = false
}
}
async function downloadSource(row: PlatformPriceBookVersion) {
const next = new Set(downloadingIds.value)
next.add(row.id)
@@ -613,26 +260,54 @@ async function downloadSource(row: PlatformPriceBookVersion) {
}
}
async function voidPending(row: PlatformPriceBookVersion) {
if (row.status !== 'PENDING_CONFIRMATION' || !canEdit.value) return
let reason = ''
try {
const result = await ElMessageBox.prompt(
`作废后,版本 V${row.versionNo} 将不能再确认生效;版本记录和原始文件会继续保留。`,
'作废待确认价格表',
{
type: 'warning',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '请输入作废原因',
inputValidator: (value) => value.trim() ? true : '请输入作废原因',
inputPattern: /^.{1,500}$/s,
inputErrorMessage: '作废原因不能超过 500 个字符',
},
)
reason = result.value.trim()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error('作废操作未完成,请重试。')
return
}
const next = new Set(voidingIds.value)
next.add(row.id)
voidingIds.value = next
try {
await voidPlatformPriceBook(row.id, reason)
ElMessage.success(`价格表 V${row.versionNo} 已作废`)
await loadPriceBooks()
} catch (error) {
ElMessage.error(apiErrorMessage(error, '价格表作废失败,请稍后重试'))
} finally {
const current = new Set(voidingIds.value)
current.delete(row.id)
voidingIds.value = current
}
}
function detailActionLabel(row: PlatformPriceBookVersion) {
return row.status === 'PENDING_CONFIRMATION' && canEdit.value ? '查看并确认' : '查看详情'
}
function closeDetail() {
if (publishing.value) return
detailOpen.value = false
}
watch(uploadOpen, (open) => {
if (!open && !uploading.value) resetUpload()
})
watch(detailOpen, (open) => {
if (open || publishing.value) return
detailSequence += 1
detailLoading.value = false
detailError.value = null
})
onMounted(() => {
void loadCustomerOptions()
void loadPriceBooks()
@@ -690,6 +365,14 @@ onMounted(() => {
:loading="downloadingIds.has((row as PlatformPriceBookVersion).id)"
@click="downloadSource(row as PlatformPriceBookVersion)"
><Download :size="15" />原文件</el-button>
<el-button
v-if="canEdit && (row as PlatformPriceBookVersion).status === 'PENDING_CONFIRMATION'"
size="small"
plain
type="danger"
:loading="voidingIds.has((row as PlatformPriceBookVersion).id)"
@click="voidPending(row as PlatformPriceBookVersion)"
><CircleX :size="15" />作废</el-button>
</div>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
@@ -728,136 +411,6 @@ onMounted(() => {
</div>
</AppDialog>
<AppDrawer
v-model="detailOpen"
:title="detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
size="min(920px, 96vw)"
:loading="detailLoading"
:show-footer="isPendingDetail && canEdit"
:confirm-loading="publishing"
:confirm-disabled="!canConfirmDetail"
confirm-text="确认并立即生效"
@confirm="confirmPriceBook"
@cancel="closeDetail"
>
<template #footer="{ close, submitting }">
<div class="price-book-footer">
<el-button :disabled="submitting" @click="close">取消</el-button>
<el-button type="primary" :loading="publishing" :disabled="detailLoading || !canConfirmDetail" @click="confirmPriceBook"><CircleCheck :size="16" />确认并立即生效</el-button>
</div>
</template>
<section v-if="detailError" class="detail-load-error" role="alert">
<strong>价格表详情暂时无法加载</strong>
<p>{{ detailError.message }}</p>
<ApiErrorMeta :details="detailError" />
<el-button type="primary" @click="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)"><RefreshCw :size="16" />重新加载</el-button>
</section>
<DetailPageLayout v-else-if="detail" class="detail-content" aria-label="船期价格表版本详情">
<template #header><header class="detail-summary">
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>{{ priceBookScopeLabel(detail.version.customerId) }} · 版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
<div class="detail-summary__actions"><el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag><el-button :loading="downloadingIds.has(detail.version.id)" @click="downloadSource(detail.version)"><Download :size="16" />下载原文件</el-button></div>
</header></template>
<template #summary><section class="detail-section version-facts">
<dl class="detail-grid">
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
<div><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
</dl>
</section></template>
<section v-if="isPendingDetail && canEdit" class="detail-section mapping-section">
<header class="detail-section__heading"><div><h3><Warehouse :size="17" />仓库映射</h3><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
type="error"
:closable="false"
show-icon
/>
<el-alert v-else-if="confirmationHint" :title="confirmationHint" :type="canConfirmDetail ? 'success' : 'warning'" :closable="false" show-icon />
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)"><RefreshCw :size="16" />重新加载</el-button>
</div>
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
<section class="mapping-group">
<h4>起运仓映射</h4>
<div v-for="row in originMappingRows" :key="row.key" class="mapping-row">
<div><strong>{{ row.sourceName }}</strong><span>来源编码:{{ row.sourceCode }}</span></div>
<el-select v-model="originSelections[row.key]" filterable placeholder="请选择匹配的国内仓">
<el-option
v-for="warehouse in domesticWarehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)"
:disabled="originWarehouseDisabled(row, warehouse)"
/>
</el-select>
</div>
</section>
<section v-if="overseasMappingRows.length" class="mapping-group">
<h4>海外仓映射</h4>
<div v-for="row in overseasMappingRows" :key="row.key" class="mapping-row">
<div><strong>{{ row.sourceName }}</strong><span>目的仓编码:{{ row.destinationCode || '未提供' }}</span></div>
<el-select v-model="overseasSelections[row.key]" filterable placeholder="请选择匹配的海外仓">
<el-option
v-for="warehouse in overseasWarehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)"
:disabled="overseasWarehouseDisabled(row, warehouse)"
/>
</el-select>
</div>
</section>
<el-alert v-if="mappingCandidatesMissing" title="未找到编码匹配的启用平台公共仓库,请先在仓库管理中补齐对应仓库。" type="warning" :closable="false" show-icon />
<el-alert v-if="hasMissingOverseasSource" title="有海外仓价格行缺少来源名称,无法确认映射。" type="error" :closable="false" show-icon />
</div>
</section>
<section class="detail-section">
<header class="detail-section__heading"><div><h3><AlertTriangle :size="17" />解析与校验</h3><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section channel-preview">
<header class="detail-section__heading"><div><h3><Ship :size="17" />按渠道预览</h3><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
</el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '-' }}</span></div>
<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
</section>
</DetailPageLayout>
</AppDrawer>
</AdminPage>
</template>
@@ -865,8 +418,6 @@ onMounted(() => {
.price-book-page { min-width: 0; }
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
.price-book-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.price-book-footer :deep(.el-button) { margin-left: 0; }
.version-cell { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.version-cell strong { color: var(--cc-text); }
.version-cell span, .metric-cell span { color: var(--cc-muted); font-size: 11px; }
@@ -884,59 +435,8 @@ onMounted(() => {
.upload-file-name span { color: var(--cc-muted); font-size: 12px; }
.upload-file-name.is-empty strong { color: var(--cc-muted); font-weight: 400; }
.upload-file-error { margin: -8px 0 0; color: var(--el-color-danger); font-size: 12px; }
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-load-error strong { color: var(--cc-danger); font-size: 15px; }
.detail-load-error p { margin: 6px 0; color: var(--cc-text); font-size: 13px; }
.detail-load-error :deep(.el-button) { margin-top: 16px; }
.detail-content { display: grid; min-width: 0; gap: 20px; }
.detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.detail-summary__title, .detail-summary__actions { display: flex; min-width: 0; align-items: flex-start; gap: 10px; }
.detail-summary__title > svg { flex: 0 0 22px; margin-top: 2px; color: var(--cc-primary); }
.detail-summary__title h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; line-height: 1.5; }
.detail-summary__title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-summary__actions { flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; }
.detail-section { display: grid; gap: 14px; min-width: 0; }
.detail-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-section__heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 14px; }
.detail-section__heading h3 svg { color: var(--cc-primary); }
.detail-section__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.version-facts { padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.detail-grid > div { min-width: 0; padding: 12px 14px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.detail-grid > div:nth-child(3n) { border-right: 0; }
.detail-grid > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
.detail-grid dt { color: var(--cc-muted); font-size: 11px; }
.detail-grid dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; font-weight: 700; }
.mapping-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
.mapping-groups { display: grid; gap: 16px; }
.mapping-group { display: grid; gap: 8px; }
.mapping-group h4 { margin: 0; color: var(--cc-text); font-size: 13px; }
.mapping-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(280px, 1fr); align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.mapping-row > div { display: grid; min-width: 0; gap: 4px; }
.mapping-row strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; }
.mapping-row span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
.mapping-load-error { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; background: #fff; }
.mapping-load-error strong { color: var(--cc-danger); font-size: 13px; }
.mapping-load-error p { margin: 4px 0; color: var(--cc-text); font-size: 12px; }
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
.channel-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -4px 0 8px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) {
.table-actions :deep(.el-button) { min-height: 40px; }
.price-book-footer :deep(.el-button) { min-height: 44px; }
.upload-file-control, .detail-summary, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-summary__actions { justify-content: flex-start; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-grid > div:nth-child(3n) { border-right: 1px solid var(--cc-border); }
.detail-grid > div:nth-child(2n) { border-right: 0; }
.detail-grid > div:nth-child(-n+3) { border-bottom: 0; }
.detail-grid > div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
.mapping-row { grid-template-columns: 1fr; gap: 10px; }
.mapping-row :deep(.el-select) { width: 100%; }
}
@media (max-width: 420px) {
.detail-grid { grid-template-columns: 1fr; }
.detail-grid > div, .detail-grid > div:nth-child(3n) { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-grid > div:last-child { border-bottom: 0; }
.upload-file-control { align-items: stretch; flex-direction: column; }
}
</style>
+11
View File
@@ -11,4 +11,15 @@ export const fbaPricingRoutes = [
permission: 'firstleg:fba-pricing:rate:view',
},
},
{
path: 'first-leg-pricing/carrier-rates/:versionId',
name: 'fba-carrier-rate-detail',
component: () => import('./carrier-rates/pages/CarrierRateDetailView.vue'),
meta: {
title: '船期价格表详情',
description: '查看价格表版本、仓库映射和渠道价格',
permission: 'firstleg:fba-pricing:rate:view',
showPageHeading: false,
},
},
] satisfies RouteRecordRaw[]
+1 -1
View File
@@ -8,7 +8,7 @@
## 客户资金账户
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、已用授信、冻结授信和总可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信和总可用余额;操作列固定第一列。授信到期日必须晚于今天且不限制最远日期,到期日当天仍有效。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
- 平台手工充值、扣款和授信调整必须提交账户版本与幂等键;扣款及授信下限由服务端校验。页面只展示服务端余额、授信和流水结果,不在浏览器补算或回退静态数据。
## 客户筛选与统计
+4 -4
View File
@@ -79,12 +79,12 @@ describe('finance api', () => {
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', accountVersion: 7, pendingRechargeCount: 2 }], page: { page: 2, pageSize: 20, total: 21 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', creditExpiresOn: '2028-09-10', creditActive: true, accountVersion: 7, pendingRechargeCount: 2 }], page: { page: 2, pageSize: 20, total: 21 } })
const result = await listFinanceAccounts({ page: 2, size: 20, customerId: '10001', status: 'ACTIVE' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, customerId: '10001', status: 'ACTIVE' } })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', accountVersion: 7, pendingRechargeCount: 2 }], total: 21, pages: 2 })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', creditExpiresOn: '2028-09-10', creditActive: true, accountVersion: 7, pendingRechargeCount: 2 }], total: 21, pages: 2 })
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
@@ -116,9 +116,9 @@ describe('finance api', () => {
it('updates the customer credit limit with account version and idempotency', async () => {
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: { ledgerId: 102, businessNo: 'AC-102', changeType: 'CREDIT_LIMIT', amount: '20000.00', creditLimitAfter: '20000.00', creditUsedAfter: '0.00', availableCreditAfter: '20000.00' } })
const result = await updateFinanceCreditLimit('10001', { creditLimit: '20000.00', remark: '初始化授信', expectedAccountVersion: 7 })
const result = await updateFinanceCreditLimit('10001', { creditLimit: '20000.00', creditExpiresOn: '2028-09-10', remark: '初始化授信', expectedAccountVersion: 7 })
expect(put).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/credit-limit', { creditLimit: '20000.00', remark: '初始化授信', expectedAccountVersion: 7 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(put).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/credit-limit', { creditLimit: '20000.00', creditExpiresOn: '2028-09-10', remark: '初始化授信', expectedAccountVersion: 7 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(result).toMatchObject({ ledgerId: 102, changeType: 'CREDIT_LIMIT', creditLimitAfter: '20000.00', availableCreditAfter: '20000.00' })
})
+4 -2
View File
@@ -141,7 +141,8 @@ function normalizeFinanceAccount(item: RawResource): FinanceAccountSummary {
paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status),
lastChangedAt: stringValue(item.lastChangedAt), actualBalance: stringValue(item.actualBalance), frozenBalance: stringValue(item.frozenBalance),
availableBalance: stringValue(item.availableBalance), creditLimit: stringValue(item.creditLimit), creditUsed: stringValue(item.creditUsed),
availableCredit: stringValue(item.availableCredit), accountVersion: numberValue(item.accountVersion), pendingRechargeCount: numberValue(item.pendingRechargeCount),
availableCredit: stringValue(item.availableCredit), creditExpiresOn: nullableStringValue(item.creditExpiresOn), creditActive: Boolean(item.creditActive),
accountVersion: numberValue(item.accountVersion), pendingRechargeCount: numberValue(item.pendingRechargeCount),
}
}
@@ -159,6 +160,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
availableCreditAfter: nullableStringValue(item.availableCreditAfter),
creditExpiresOnAfter: nullableStringValue(item.creditExpiresOnAfter),
sourceNo: nullableStringValue(item.sourceNo), occurredDate: nullableStringValue(item.occurredDate),
paymentMethod: nullableStringValue(item.paymentMethod), referenceNo: nullableStringValue(item.referenceNo),
createdAt: nullableStringValue(item.createdAt),
@@ -201,7 +203,7 @@ export async function adjustFinanceAccount(customerId: string, payload: FinanceA
return unwrapApiResponse(response)
}
export async function updateFinanceCreditLimit(customerId: string, payload: { creditLimit: string; remark?: string; expectedAccountVersion: number }) {
export async function updateFinanceCreditLimit(customerId: string, payload: { creditLimit: string; creditExpiresOn: string; remark?: string; expectedAccountVersion: number }) {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${customerId}/credit-limit`, payload, mutationHeaders('credit-limit'))
return unwrapApiResponse(response)
}
@@ -38,7 +38,7 @@ function memoryStorage(): Storage {
const accountFixture = {
customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000',
outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, totalCost: 90, paidCost: 50, outstandingPayable: 40,
actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', accountVersion: 7, pendingRechargeCount: 1,
actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '138080.50', creditLimit: '20000.00', creditUsed: '0.00', availableCredit: '20000.00', creditExpiresOn: '2028-09-10', creditActive: true, accountVersion: 7, pendingRechargeCount: 1,
status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00',
}
@@ -151,7 +151,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
vi.mocked(listFinanceRechargeRequests).mockReset().mockResolvedValue({ records: [{ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' }], total: 1, pages: 1 })
vi.mocked(reviewFinanceRechargeRequest).mockReset().mockResolvedValue({ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '2026-09-05T10:00:00' })
vi.mocked(adjustFinanceAccount).mockReset().mockResolvedValue({ ...accountFixture, actualBalance: '127080.50', availableBalance: '118580.50', accountVersion: 8 })
vi.mocked(updateFinanceCreditLimit).mockReset().mockResolvedValue({ ledgerId: '102', businessNo: 'AC-102', changeType: 'CREDIT_LIMIT', amount: '20000.00', actualBalanceAfter: '126580.50', frozenBalanceAfter: '8500.00', availableBalanceAfter: '138080.50', creditLimitAfter: '20000.00', creditUsedAfter: '0.00', availableCreditAfter: '20000.00', sourceType: 'CREDIT_LIMIT_ADJUSTMENT', sourceNo: '', occurredAt: '', occurredDate: '2026-09-05', paymentMethod: '', referenceNo: '', operatorName: '财务A', remark: '', createdAt: '2026-09-05T10:00:00' })
vi.mocked(updateFinanceCreditLimit).mockReset().mockResolvedValue({ ledgerId: '102', businessNo: 'AC-102', changeType: 'CREDIT_LIMIT', amount: '20000.00', actualBalanceAfter: '126580.50', frozenBalanceAfter: '8500.00', availableBalanceAfter: '138080.50', creditLimitAfter: '20000.00', creditUsedAfter: '0.00', availableCreditAfter: '20000.00', creditExpiresOnAfter: '2028-09-10', sourceType: 'CREDIT_LIMIT_ADJUSTMENT', sourceNo: '', occurredAt: '', occurredDate: '2026-09-05', paymentMethod: '', referenceNo: '', operatorName: '财务A', remark: '', createdAt: '2026-09-05T10:00:00' })
vi.mocked(downloadFinanceRechargeVoucher).mockReset().mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never)
@@ -179,7 +179,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(metrics.text()).toContain('实际余额¥126580.50CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50CNY')
expect(metrics.text()).toContain('授信额度¥20000.00CNY')
expect(metrics.text()).toContain('授信额度¥20000.002028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00CNY')
expect(metrics.text()).toContain('可用授信¥20000.00CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
@@ -58,7 +58,7 @@ const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
const adjustmentForm = reactive({ type: 'RECHARGE' as 'RECHARGE' | 'DEDUCTION', amount: '', occurredDate: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', remark: '' })
const creditLimitOpen = ref(false)
const creditLimitSaving = ref(false)
const creditLimitForm = reactive({ amount: '', remark: '' })
const creditLimitForm = reactive({ amount: '', expiresOn: '', remark: '' })
const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewRequest = ref<FinanceRechargeRequest | null>(null)
@@ -90,6 +90,7 @@ const columns: DataTableColumn[] = [
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
@@ -216,6 +217,15 @@ function today() {
const day = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function disableCreditExpiry(date: Date) {
const boundary = new Date()
boundary.setHours(0, 0, 0, 0)
return date.getTime() <= boundary.getTime()
}
function creditExpiryLabel(account: FinanceAccountSummary) {
if (!account.creditExpiresOn) return '未设置'
return account.creditActive ? `${account.creditExpiresOn} 到期` : `${account.creditExpiresOn} 已到期`
}
function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
@@ -247,7 +257,11 @@ async function submitAdjustment() {
}
function openCreditLimit(account: FinanceAccountSummary) {
adjustmentAccount.value = account
Object.assign(creditLimitForm, { amount: Number(account.creditLimit || 0).toFixed(2), remark: '' })
Object.assign(creditLimitForm, {
amount: Number(account.creditLimit || 0).toFixed(2),
expiresOn: account.creditActive ? account.creditExpiresOn || '' : '',
remark: '',
})
creditLimitOpen.value = true
}
function handleAccountMore(command: string, account: FinanceAccountSummary) {
@@ -262,10 +276,15 @@ async function submitCreditLimit() {
ElMessage.warning('授信额度必须是大于或等于 0 且最多两位小数的金额')
return
}
if (!creditLimitForm.expiresOn || creditLimitForm.expiresOn <= today()) {
ElMessage.warning('授信到期日必须晚于今天')
return
}
creditLimitSaving.value = true
try {
await updateFinanceCreditLimit(adjustmentAccount.value.customerId, {
creditLimit: amount.toFixed(2), remark: creditLimitForm.remark.trim() || undefined,
creditLimit: amount.toFixed(2), creditExpiresOn: creditLimitForm.expiresOn,
remark: creditLimitForm.remark.trim() || undefined,
expectedAccountVersion: adjustmentAccount.value.accountVersion,
})
creditLimitOpen.value = false
@@ -408,7 +427,7 @@ onBeforeUnmount(clearVoucherPreview)
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #actualBalance="{ row }"><strong class="balance-value is-actual">{{ money(row.actualBalance) }}</strong></template><template #frozenBalance="{ row }"><span class="balance-value is-frozen">{{ money(row.frozenBalance) }}</span></template><template #availableBalance="{ row }"><strong class="balance-value">{{ money(row.availableBalance) }}</strong></template><template #creditLimit="{ row }">{{ money(row.creditLimit) }}</template><template #creditUsed="{ row }"><span class="balance-value is-frozen">{{ money(row.creditUsed) }}</span></template>
<template #actualBalance="{ row }"><strong class="balance-value is-actual" :class="{ 'is-negative': Number(row.actualBalance) < 0 }">{{ money(row.actualBalance) }}</strong></template><template #frozenBalance="{ row }"><span class="balance-value is-frozen">{{ money(row.frozenBalance) }}</span></template><template #availableBalance="{ row }"><strong class="balance-value">{{ money(row.availableBalance) }}</strong></template><template #creditLimit="{ row }">{{ money(row.creditLimit) }}</template><template #creditExpiresOn="{ row }"><span :class="{ 'credit-expired': row.creditExpiresOn && !row.creditActive }">{{ creditExpiryLabel(row as FinanceAccountSummary) }}</span></template><template #creditUsed="{ row }"><span class="balance-value is-frozen">{{ money(row.creditUsed) }}</span></template>
<template #pendingRechargeCount="{ row }"><el-tag v-if="row.pendingRechargeCount" type="warning">{{ row.pendingRechargeCount }} 笔</el-tag><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
@@ -419,7 +438,7 @@ onBeforeUnmount(clearVoucherPreview)
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · CNY 资金账户</p></div><div v-if="selected && !editing" class="detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><span>实际余额</span><strong>{{ money(selected.actualBalance) }}</strong><small>CNY</small></div><div class="account-metric is-frozen"><span>冻结金额</span><strong>{{ money(selected.frozenBalance) }}</strong><small>CNY</small></div><div class="account-metric is-available"><span>总可用余额</span><strong>{{ money(selected.availableBalance) }}</strong><small>CNY</small></div><div class="account-metric"><span>授信额度</span><strong>{{ money(selected.creditLimit) }}</strong><small>CNY</small></div><div class="account-metric is-frozen"><span>已用授信</span><strong>{{ money(selected.creditUsed) }}</strong><small>CNY</small></div><div class="account-metric"><span>可用授信</span><strong>{{ money(selected.availableCredit) }}</strong><small>CNY</small></div></section></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><span>实际余额</span><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong><small>CNY</small></div><div class="account-metric is-frozen"><span>冻结金额</span><strong>{{ money(selected.frozenBalance) }}</strong><small>CNY</small></div><div class="account-metric is-available"><span>总可用余额</span><strong>{{ money(selected.availableBalance) }}</strong><small>CNY</small></div><div class="account-metric"><span>授信额度</span><strong>{{ money(selected.creditLimit) }}</strong><small>{{ creditExpiryLabel(selected) }}</small></div><div class="account-metric is-frozen"><span>已用授信</span><strong>{{ money(selected.creditUsed) }}</strong><small>CNY</small></div><div class="account-metric"><span>可用授信</span><strong>{{ money(selected.availableCredit) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
@@ -448,8 +467,8 @@ onBeforeUnmount(clearVoucherPreview)
<el-form label-position="top" class="wallet-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><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="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
<el-alert title="授信额度不能低于已用授信与冻结授信之和;修改后立即影响客户总可用余额。" type="info" :closable="false" />
<el-form label-position="top" class="wallet-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
<el-form label-position="top" class="wallet-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-descriptions v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
@@ -570,6 +589,12 @@ onBeforeUnmount(clearVoucherPreview)
color: var(--el-color-warning);
}
.balance-value.is-negative,
.account-metric strong.is-negative,
.credit-expired {
color: var(--el-color-danger);
}
.voucher-preview {
display: grid;
min-height: 320px;
+3
View File
@@ -202,6 +202,8 @@ export interface FinanceAccountSummary {
creditLimit: string
creditUsed: string
availableCredit: string
creditExpiresOn: string | null
creditActive: boolean
accountVersion: number
pendingRechargeCount: number
}
@@ -248,6 +250,7 @@ export interface FinanceLedgerEntry {
creditLimitAfter?: string | null
creditUsedAfter?: string | null
availableCreditAfter?: string | null
creditExpiresOnAfter?: string | null
sourceType?: string | null
sourceNo?: string | null
occurredDate?: string | null
+15
View File
@@ -17,6 +17,7 @@ import {
deletePlatformPermission,
deletePlatformRole,
downloadTenantQualification,
getPlatformTenantDimensionInitializationStatus,
listPlatformTenants,
listPlatformTenantOrderCounts,
listTenantQualifications,
@@ -400,6 +401,20 @@ describe('platform IAM roles', () => {
expect(form.get('replaceLegalPersonId')).toBe('true')
})
it('loads the customer dimension initialization status before querying dimension rules', async () => {
const status = {
tenantId: '2098016473058967554', status: 'PENDING', attemptCount: 3,
nextRetryAt: '2026-09-10T20:04:00', lastErrorCode: 'S0503',
lastErrorMessage: 'Firstleg 服务暂时不可用', updatedAt: '2026-09-10T19:54:00',
}
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: status })
await expect(getPlatformTenantDimensionInitializationStatus(status.tenantId)).resolves.toEqual(status)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/iam/tenants/2098016473058967554/dimension-initialization',
)
})
it('omits the optional legal-person ID parts when only the business license is uploaded', async () => {
const result = {
businessLicenseQualificationId: '91', legalPersonIdQualificationId: null,
+18 -1
View File
@@ -29,6 +29,7 @@ export interface PlatformTenantResource {
tenantCode: string
tenantName: string
status: PlatformTenantStatus
dimensionInitializationStatus?: 'PENDING' | 'SENDING' | 'READY' | 'FAILED' | string
legalName: string | null
legalRepresentative: string | null
unifiedSocialCreditCode: string | null
@@ -52,7 +53,7 @@ export interface PlatformTenantResource {
export interface CreatePlatformTenantRequest {
tenantName: string
legalName: string
legalRepresentative: string
legalRepresentative?: string
contactName: string
contactPhone: string
contactEmail: string
@@ -147,6 +148,16 @@ export interface PlatformTenantCreationResponse {
temporaryPassword: string
}
export interface CustomerDimensionInitializationStatus {
tenantId: number | string
status: 'PENDING' | 'SENDING' | 'READY' | 'FAILED' | string
attemptCount: number
nextRetryAt: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
updatedAt: string | null
}
export interface ResetPlatformTenantOwnerPasswordRequest {
newPassword: string
forceChangePassword: boolean
@@ -363,6 +374,12 @@ export function getPlatformTenant(tenantId: number | string) {
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
}
export function getPlatformTenantDimensionInitializationStatus(tenantId: number | string) {
return getData<CustomerDimensionInitializationStatus>(
`/platform/v1/iam/tenants/${tenantId}/dimension-initialization`,
)
}
export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) {
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
'/platform/v1/iam/tenants/contact-phone-availability',
+10 -2
View File
@@ -93,10 +93,18 @@ describe('RolesView', () => {
vi.mocked(createPlatformRole).mockReset()
})
it('keeps directory, menu, and action permissions independently checkable', () => {
it('links menu groups and permissions so partial selections use the half-checked state', () => {
const wrapper = mountView()
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true')
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('false')
expect(rolesSource).not.toContain('check-strictly')
expect(rolesSource).not.toContain("disabled: 'disabled'")
})
it('restores saved permission ids after the dialog tree has mounted', () => {
expect(rolesSource).toContain('@opened="initializePermissionSelection"')
expect(rolesSource).toContain('treeRef.value?.setCheckedKeys([...form.permissionIds])')
expect(rolesSource).not.toContain('nextTick(() =>')
})
it('shows the role code in the default list layout', () => {
+19 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
import {
@@ -24,8 +24,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { adminNavigation } from '@/core/navigation/adminNavigation'
import type { Permission, Role } from '@/modules/iam/types'
import { buildPermissionTree, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
import { buildNavigationPermissionTree, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
import { mapPlatformPermission } from '@/modules/iam/utils/platformPermission'
interface TreeController {
@@ -88,7 +89,9 @@ const roleColumns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
const availablePermissions = computed(() => serverPermissions.value)
const permissionTree = computed(() => buildPermissionTree(availablePermissions.value))
const permissionTree = computed(() => buildNavigationPermissionTree(availablePermissions.value, adminNavigation))
const permissionGroupIds = computed(() => permissionTree.value.map((node) => node.id))
const assignablePermissionIds = computed(() => new Set(availablePermissions.value.map((permission) => permission.id)))
function applyFilters() {
appliedFilters.value = { ...filters.value }
@@ -133,19 +136,22 @@ function openCreate() {
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
nextTick(() => { formRef.value?.clearValidate(); treeRef.value?.setCheckedKeys([]) })
}
function openEdit(row: Role) {
if (!canEdit.value && !canGrant.value) return
Object.assign(form, { ...row, permissionIds: [...row.permissionIds] })
captureBaseline()
editorOpen.value = true
nextTick(() => { formRef.value?.clearValidate(); treeRef.value?.setCheckedKeys([...row.permissionIds]) })
}
function initializePermissionSelection() {
formRef.value?.clearValidate()
treeRef.value?.setCheckedKeys([...form.permissionIds])
}
function selectedPermissionIds() {
const checked = treeRef.value?.getCheckedKeys(false).map(String) || []
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String) || []
const checked = treeRef.value?.getCheckedKeys(false).map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
return [...new Set([...checked, ...halfChecked])]
}
@@ -250,7 +256,7 @@ onMounted(loadRoles)
</section>
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @confirm="saveRole">
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form">
<div class="role-editor-layout">
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic">
@@ -258,7 +264,7 @@ onMounted(loadRoles)
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
</FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`保存时按后端契约全量替换当前角色权限;当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span><code v-if="data.code">{{ data.code }}</code></div></template></el-tree></FormSection>
</div>
</el-form>
</AppDialog>
@@ -268,7 +274,10 @@ onMounted(loadRoles)
<style scoped>
.role-editor-layout { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); gap: 20px; }
.role-editor-permissions { min-width: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
.permission-tree { max-height: 380px; overflow-y: auto; }
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
.permission-node span { min-width: 0; }
.permission-node code { max-width: 58%; white-space: normal; text-align: right; }
.permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
:global(.role-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
+22 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Permission } from '@/modules/iam/types'
import { buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
import { buildNavigationPermissionTree, buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
import { adminNavigation } from '@/core/navigation/adminNavigation'
const permissions: Permission[] = [
{ id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
@@ -52,4 +53,24 @@ describe('permissionTree', () => {
actionCount: 1,
})
})
it('groups assignable permissions by the current admin navigation and hides technical containers', () => {
const rolePermissions: Permission[] = [
{ id: 'supplier', parentId: null, name: '供应商旧名称', code: 'platform:supplier:list', type: '菜单', path: '/suppliers', method: 'GET', status: '启用', sort: 10 },
{ id: 'supplier-create', parentId: 'supplier', name: '新增供应商', code: 'platform:supplier:create', type: '按钮', path: '', method: 'POST', status: '启用', sort: 10 },
{ id: 'express', parentId: null, name: '快递公司', code: 'platform:express:list', type: '菜单', path: '/master-data/express-companies', method: 'GET', status: '启用', sort: 20 },
{ id: 'counterparty', parentId: null, name: '往来单位', code: 'platform:counterparty:list', type: '菜单', path: '/master-data/counterparties', method: 'GET', status: '启用', sort: 30 },
{ id: 'technical', parentId: null, name: '服务接口', code: 'platform:technical:invoke', type: '接口', path: '/internal', method: 'POST', status: '启用', sort: 40 },
]
const tree = buildNavigationPermissionTree(rolePermissions, adminNavigation)
const general = tree.find((node) => node.name === '通用')
expect(tree.some((node) => node.name === '供应商中心')).toBe(false)
expect(general?.permissionId).toBeUndefined()
expect(general?.children?.map((node) => node.name)).toEqual(['供应商列表', '快递公司', '往来单位'])
expect(general?.children?.[0]?.children?.map((node) => node.name)).toEqual(['供应商旧名称', '新增供应商'])
expect(tree.some((node) => node.name === '其他权限')).toBe(false)
expect(JSON.stringify(tree)).not.toContain('platform:technical:invoke')
})
})
+66
View File
@@ -1,7 +1,15 @@
import type { Permission } from '@/modules/iam/types'
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] }
export type PermissionMatcher = (permission: Permission) => boolean
export interface RolePermissionTreeNode {
id: string
permissionId?: string
name: string
code: string
children?: RolePermissionTreeNode[]
}
function groupByParent(permissions: Permission[]) {
const childrenByParent = new Map<string | null, Permission[]>()
@@ -73,3 +81,61 @@ export function summarizePermissionSelection(permissions: Permission[], selected
actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length,
}
}
export function buildNavigationPermissionTree(
permissions: Permission[],
navigation: AdminNavigationNode[],
): RolePermissionTreeNode[] {
const childrenByParent = groupByParent(permissions)
const permissionByCode = new Map(permissions.map((permission) => [permission.code, permission]))
const usedPermissionIds = new Set<string>()
function permissionLeaves(permission: Permission, ancestors: Set<string>): RolePermissionTreeNode[] {
if (ancestors.has(permission.id)) return []
usedPermissionIds.add(permission.id)
const nextAncestors = new Set(ancestors).add(permission.id)
const children = (childrenByParent.get(permission.id) ?? [])
.flatMap((child) => permissionLeaves(child, nextAncestors))
return [{ id: permission.id, permissionId: permission.id, name: permission.name, code: permission.code }, ...children]
}
function navigationNode(node: AdminNavigationNode): RolePermissionTreeNode | null {
if (node.hidden) return null
if (node.children?.length) {
const children = node.children
.map(navigationNode)
.filter((child): child is RolePermissionTreeNode => child !== null)
if (!children.length) return null
return {
id: `navigation-group:${node.key}`,
name: node.title,
code: '',
children,
}
}
const codes = [...new Set([node.permission, ...(node.permissionAnyOf ?? [])].filter(Boolean))] as string[]
const permissionNodes = codes
.map((code) => permissionByCode.get(code))
.filter((permission): permission is Permission => permission !== undefined)
.flatMap((permission) => usedPermissionIds.has(permission.id) ? [] : permissionLeaves(permission, new Set()))
if (permissionNodes.length) {
return {
id: `navigation-menu:${node.key}`,
name: node.title,
code: '',
children: permissionNodes,
}
}
return null
}
const navigationGroups = navigation
.map(navigationNode)
.filter((node): node is RolePermissionTreeNode => node !== null)
return navigationGroups
}
@@ -16,6 +16,7 @@ describe('InboundHistoryPanel dropship pallet result', () => {
it('keeps the existing scoped pallet-label action available', () => {
expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)')
expect(source).toContain('打印托盘唛')
expect(source).toContain('v-if="pallet.palletNo"')
expect(source).toContain('打印托盘码')
})
})
@@ -55,7 +55,7 @@ async function printPallet(pallet: InboundDropshipPallet) {
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘码</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
+15 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
import { downloadInboundCartonLabels, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -131,6 +131,20 @@ describe('platform inbound query API', () => {
})
})
it('downloads one PDF for a cross-customer batch of inbound orders', async () => {
const pdf = new Blob(['pdf'], { type: 'application/pdf' })
vi.mocked(http.post).mockResolvedValueOnce(pdf)
const items = [
{ tenantId: '10001', inboundId: '19001' },
{ tenantId: '10002', inboundId: '19002' },
]
await expect(downloadInboundCartonLabelsBatch(items)).resolves.toBe(pdf)
expect(http.post).toHaveBeenCalledWith('/platform/inbounds/carton-label-pdf', items, {
responseType: 'blob', timeout: 120_000,
})
})
it('loads and normalizes the tenant-scoped hold state and immutable history', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
inboundId: 19001, tenantId: 10002, holdFlag: true, currentHoldReason: ' 待核对货款 ', version: '4',
+19
View File
@@ -266,6 +266,25 @@ export async function downloadInboundCartonLabels(tenantId: string, inboundId: s
return response
}
export interface InboundCartonLabelBatchItem {
tenantId: string
inboundId: string
}
export async function downloadInboundCartonLabelsBatch(items: InboundCartonLabelBatchItem[]) {
const response = await http.post<Blob, Blob>(
`${basePath}/carton-label-pdf`,
items,
{ responseType: 'blob', timeout: 120_000 },
)
if (response.type.includes('json')) {
const error = JSON.parse(await response.text()) as Partial<ApiEnvelope<unknown>>
throw new ApiResponseError(error.message || '批量箱唛生成失败', error.code || 'UNKNOWN', undefined,
error.data, error.requestId, error.traceId)
}
return response
}
export async function downloadDropshipPalletLabel(tenantId: string, inboundId: string, palletId: string) {
return http.get<Blob, Blob>(
`/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets/${encodeURIComponent(palletId)}/print`,
+5 -3
View File
@@ -1,17 +1,19 @@
export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api'
export { createInboundHoldIdempotencyKey, downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './core/api'
export { getInboundMeasurement, saveInboundMeasurementDraft, updateMeasurementCalculationStrategy, updateMeasurementOuterDimensions } from './measurement/api'
export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './core/api'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
adjustPublishedInboundFee,
calculateInboundFee,
confirmInboundFee,
createFeeAdjustmentIdempotencyKey,
createFeeActionIdempotencyKey,
getLatestInboundFee,
sendInboundFee,
} from './fee/api'
export type { InboundFeeSnapshot } from './fee/types'
export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee/types'
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
@@ -21,7 +21,8 @@ describe('InboundMeasurementDialog', () => {
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)')
expect(source).toContain('measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)')
expect(source).toContain('measurementPanelRef?.submissionBlockReason')
})
it('offers the shared fullscreen control without forcing fullscreen on open', () => {
@@ -45,8 +45,12 @@ async function beforeClose(done: () => void) {
if (await mayClose()) done()
}
function handleWeightSummaryChanged(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)
function handleWeightSummaryChanged(
actualGrossWeightKg: number | null,
actualNetWeightKg: number | null,
hasConfirmedWeight: boolean,
) {
measurementPanelRef.value?.updateWeightEvidence(actualGrossWeightKg, actualNetWeightKg, hasConfirmedWeight)
}
function handleUpdated() {
@@ -95,6 +99,9 @@ function handleSubmitted() {
</div>
<template #footer>
<div class="measurement-dialog-footer">
<span v-if="measurementPanelRef?.submissionBlockReason" class="measurement-dialog-footer__hint">
{{ measurementPanelRef.submissionBlockReason }}
</span>
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
<el-button
v-if="measurementPanelRef?.canEdit"
@@ -119,7 +126,8 @@ function handleSubmitted() {
<style scoped>
.measurement-dialog-workspace { display: grid; min-width: 0; }
.measurement-dialog-weight { min-width: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border); }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer__hint { margin-right: auto; color: var(--el-color-warning-dark-2); font-size: 12px; }
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) {
.measurement-dialog-weight { padding-bottom: 14px; }
@@ -112,9 +112,20 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
outerLengthCm: null,
outerWidthCm: null,
outerHeightCm: null,
calculatedOuterLengthCm: 50,
calculatedOuterWidthCm: 40,
calculatedOuterHeightCm: 30,
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
remark: '设备自动回传',
}],
weightResults: [],
weightResults: [{
id: '30001', fileId: '40001', palletId: '50001', palletCode: 'TP-001', palletType: '木托盘',
palletCapacityCbm: 1.2, palletTareWeightKg: 10, ocrProvider: 'FILE_WEIGHT_OCR', ocrRawText: '120',
ocrWeightValue: 120, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: null,
adjustedWeightUnit: null, grossWeightKg: 120, netWeightKg: 110, recognitionStatus: 'CONFIRMED',
adjustmentReason: null, adjustedBy: null, adjustedAt: null, confirmedBy: '9',
confirmedAt: '2026-08-04T10:35:00+08:00', version: 1,
}],
...overrides,
}
}
@@ -271,7 +282,7 @@ describe('InboundMeasurementPanel', () => {
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
})
it('renders customer declaration facts as plain read-only values', async () => {
@@ -289,6 +300,8 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
expect(declaration.findAll('input')).toHaveLength(1)
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('蓝牙音箱')
expect(declaration.find('[aria-label="客户申报箱规明细"]').text()).toContain('50 × 40 × 30')
})
it('does not render differences when the corresponding declaration is unavailable', async () => {
@@ -345,7 +358,7 @@ describe('InboundMeasurementPanel', () => {
const countInput = wrapper.find('.measurement-count-editor input')
await countInput.setValue('9')
wrapper.vm.updateActualWeights(125.75, 115.75)
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
await flushPromises()
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
@@ -353,6 +366,22 @@ describe('InboundMeasurementPanel', () => {
expect((countInput.element as HTMLInputElement).value).toBe('9')
})
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
const wrapper = await mountPanel()
await wrapper.get('.el-table__expand-icon').trigger('click')
await flushPromises()
const text = wrapper.text()
expect(text).toContain('箱规')
expect(text).toContain('50×40×30cm')
expect(text).toContain('计算方式')
expect(text).toContain('实测原尺寸')
expect(text).toContain('计算后外箱')
expect(text).toContain('50 × 40 × 30 cm')
expect(text).toContain('计算规则版本')
expect(text).toContain('DIMENSION_ACTUAL_V1')
})
it('reuses the established first-leg detail surface and status patterns', async () => {
const wrapper = await mountPanel()
@@ -382,9 +411,24 @@ describe('InboundMeasurementPanel', () => {
})
})
it('does not submit a second count update when the full draft already saved the changed count', async () => {
const wrapper = await mountPanel()
await wrapper.find('.measurement-count-editor input').setValue('9')
await wrapper.find('input[aria-label="仓库修改外箱长"]').setValue('55')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
await save?.trigger('click')
await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
version: 2,
items: [expect.objectContaining({ packageCount: 9, outerLengthCm: 55 })],
}))
expect(updateConfirmedPackageCount).not.toHaveBeenCalled()
})
it('preserves the edited form when the backend reports a version conflict', async () => {
vi.mocked(updateConfirmedPackageCount).mockRejectedValueOnce(
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
new ApiResponseError('测量数据已被其他人修改,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
)
const wrapper = await mountPanel()
const countInput = wrapper.find('.measurement-count-editor input')
@@ -463,6 +507,33 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('requires at least one confirmed weight image before submitting for review', async () => {
const messageSpy = vi.spyOn(ElMessage, 'warning')
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ weightResults: [] }))
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
expect(submit?.attributes('disabled')).toBeDefined()
await wrapper.vm.submitForReview()
expect(messageSpy).toHaveBeenCalledWith('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
expect(submitInboundMeasurement).not.toHaveBeenCalled()
})
it('updates submission eligibility when the weight panel confirms or removes the last image', async () => {
const wrapper = await mountPanel()
expect(wrapper.vm.maySubmit).toBe(true)
wrapper.vm.updateWeightEvidence(null, null, false)
await flushPromises()
expect(wrapper.vm.maySubmit).toBe(false)
expect(wrapper.vm.submissionBlockReason).toContain('必须上传并确认')
wrapper.vm.updateWeightEvidence(125.75, 115.75, true)
await flushPromises()
expect(wrapper.vm.maySubmit).toBe(true)
})
it('emits updated without closing the parent task after manual carton entry', async () => {
const wrapper = await mountPanel()
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
@@ -38,12 +38,17 @@ type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coeffici
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
calculatedOuterLengthCm: number | null
calculatedOuterWidthCm: number | null
calculatedOuterHeightCm: number | null
calculationRuleVersion: string | null
}
interface MeasurementFormModel {
measurementId: string | null
version: number
actualGoodsName: string
expectedPalletCount: number | null
items: EditableMeasurementItem[]
}
@@ -76,6 +81,7 @@ const imagePreviewUrl = ref('')
const imagePreviewError = ref<ApiErrorDetails | null>(null)
const validationErrors = ref<Record<string, string[]>>({})
const baseline = ref('')
const confirmedWeightAvailable = ref(false)
let requestSequence = 0
let itemSequence = 0
@@ -83,6 +89,7 @@ const form = reactive<MeasurementFormModel>({
measurementId: null,
version: 0,
actualGoodsName: '',
expectedPalletCount: null,
items: [],
})
@@ -103,16 +110,27 @@ const canManualEntry = computed(() => access.can('platform:firstleg:inbound:meas
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value)
const manualEntryEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const manualEntryEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayCreateManualEntry = computed(() => canManualEntry.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && !loading.value && !saving.value)
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !submitEditableProcess.value || !form.measurementId) return ''
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
if (!expectedPalletCountValid.value) return '请先填写并保存有效的预计托数后再提交审核'
if (isDirty.value) return '请先保存当前修改,再提交审核'
return ''
})
const snapshot = computed(() => JSON.stringify(form))
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
@@ -297,13 +315,19 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
},
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
calculatedOuterLengthCm: item.calculatedOuterLengthCm ?? null,
calculatedOuterWidthCm: item.calculatedOuterWidthCm ?? null,
calculatedOuterHeightCm: item.calculatedOuterHeightCm ?? null,
calculationRuleVersion: item.calculationRuleVersion ?? null,
}
}
function clearForm() {
confirmedWeightAvailable.value = false
form.measurementId = null
form.version = 0
form.actualGoodsName = ''
form.expectedPalletCount = null
form.items.splice(0)
baseline.value = JSON.stringify(form)
captureBaseline()
@@ -311,9 +335,11 @@ function clearForm() {
function applyDetail(resource: InboundMeasurementDetail) {
detail.value = resource
confirmedWeightAvailable.value = resource.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED')
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
form.version = resource.version ?? 0
form.actualGoodsName = resource.actualGoodsName || ''
form.expectedPalletCount = resource.expectedPalletCount ?? null
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
baseline.value = JSON.stringify(form)
captureBaseline()
@@ -360,6 +386,8 @@ function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
if (isDropship.value && (!Number.isInteger(form.expectedPalletCount) || Number(form.expectedPalletCount) < 1 || Number(form.expectedPalletCount) > 1000)) general.push('一件代发预计托数必须是 1 至 1,000 的整数')
else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条实际扫描箱记录')
form.items.forEach((item, index) => {
const itemErrors: string[] = []
@@ -410,6 +438,7 @@ function setOperationError(error: unknown, fallback: string) {
} : source
const conflict = details.message.includes('版本已变化')
|| details.message.includes('并发修改')
|| details.message.includes('已被其他人修改')
conflictDetected.value = conflict
operationError.value = conflict ? details : null
if (!conflict) {
@@ -430,7 +459,7 @@ async function saveDraft() {
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode || 'ACTUAL', remark: item.remark || null,
})]))
const draftChanged = Boolean(detail.value && ((detail.value.actualGoodsName || '') !== form.actualGoodsName || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
const draftChanged = Boolean(detail.value && ((detail.value.actualGoodsName || '') !== form.actualGoodsName || (detail.value.expectedPalletCount ?? null) !== form.expectedPalletCount || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode, remark: item.remark || null,
@@ -442,6 +471,7 @@ async function saveDraft() {
measurementId: form.measurementId,
version,
actualGoodsName: form.actualGoodsName.trim() || null,
expectedPalletCount: isDropship.value ? form.expectedPalletCount : null,
items: form.items.map((item) => ({
inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: item.packageCount, lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
@@ -453,12 +483,14 @@ async function saveDraft() {
})
version = saved.version
}
for (const item of changedItems) {
const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, {
measurementVersion: version,
confirmedPackageCount: item.packageCount,
})
version = saved.measurementVersion
if (!draftChanged) {
for (const item of changedItems) {
const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, {
measurementVersion: version,
confirmedPackageCount: item.packageCount,
})
version = saved.measurementVersion
}
}
form.version = version
ElMessage.success('确认箱数已保存,计算结果已由服务端更新')
@@ -473,6 +505,10 @@ async function saveDraft() {
}
async function submitForReview() {
if (!hasConfirmedWeightResult.value) {
ElMessage.warning('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
return
}
if (!maySubmit.value || !form.measurementId) return
if (!validateDraft()) return
if (isDirty.value) {
@@ -554,9 +590,18 @@ function processStatusLabel(status: string | undefined) {
return inboundStatusLabel(status, inboundProcessStatusLabels)
}
function updateActualWeights(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
function updateWeightEvidence(
actualGrossWeightKg: number | null,
actualNetWeightKg: number | null,
hasConfirmedWeight: boolean,
) {
if (!detail.value) return
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
confirmedWeightAvailable.value = hasConfirmedWeight
}
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
}
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
@@ -568,6 +613,7 @@ watch(
() => {
requestSequence += 1
detail.value = null
confirmedWeightAvailable.value = false
loadError.value = null
operationError.value = null
conflictDetected.value = false
@@ -580,7 +626,7 @@ watch(
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
</script>
<template>
@@ -609,6 +655,16 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</LoadErrorState>
<template v-else-if="detail">
<el-alert
v-if="detail.processStatus === 'PREPARING'"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
title="订单尚待客服审核,可先完成到仓称重和测量"
>
已采集数据会保留并在订单审核通过后继续使用;订单审核前不能审核测量、计费或进入分柜。
</el-alert>
<el-alert v-if="!countEditableProcess" type="info" :closable="false" show-icon>
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
确认箱数只允许在待测量、测量中、待审核或审核驳回状态维护。
@@ -616,6 +672,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
<el-alert
v-if="submissionBlockReason && !hasConfirmedWeightResult"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
:title="submissionBlockReason"
/>
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>数据版本已变化,当前修改尚未丢失</template>
<p>{{ operationError?.message }}</p>
@@ -672,8 +737,26 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<div class="measurement-readonly-field"><span>申报体积(m³)</span><strong>{{ formatNumber(detail.originalVolumeCbm, 4) }}</strong></div>
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
<label v-if="isDropship && mayMaintain"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</div>
<el-table
v-if="detail.latestDeclaration?.items.length"
:data="detail.latestDeclaration.items"
border
stripe
size="small"
class="measurement-declaration-table"
aria-label="客户申报箱规明细"
>
<el-table-column prop="itemNo" label="#" width="70" />
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
<el-table-column prop="packageCount" label="申报箱数" width="100" />
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
</el-table>
</section>
</section>
@@ -697,11 +780,16 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<el-table-column type="expand" width="42">
<template #default="{ row }">
<dl class="measurement-row-details">
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
@@ -763,7 +851,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</form>
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-if="!hasConfirmedWeightResult"><AlertTriangle :size="15" />至少上传并确认一张称重图片后才能提交审核</span>
<span v-else-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>所有修改已保存</span>
<div>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
@@ -822,6 +911,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-panel h3 { font-size: 14px; }
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-declaration-table { margin-top: 12px; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: 24px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
+30 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount, updateMeasurementCalculationStrategy, updateMeasurementOuterDimensions } from './api'
import type { SaveInboundMeasurementDraftRequest } from './types'
vi.mock('@/shared/api/http', () => ({
@@ -76,6 +76,35 @@ describe('inbound measurement API', () => {
)
})
it('updates calculation strategy without resaving the frozen measurement batch', async () => {
const payload = { measurementId: '9001', version: 3, coefficientMode: 'ADD' as const, chargeRuleId: null }
const saved = { ...payload, version: 4, status: 'SUBMITTED' }
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
await expect(updateMeasurementCalculationStrategy('10001', '7001', payload)).resolves.toEqual(saved)
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/7001/measurement/calculation-strategy',
payload,
{ params: { tenantId: '10001' } },
)
})
it('updates one warehouse outer dimension row without resaving the frozen measurement batch', async () => {
const payload = {
measurementId: '9001', version: 4,
outerLengthCm: 30, outerWidthCm: 20, outerHeightCm: 20,
}
const saved = { measurementId: '9001', version: 5, status: 'SUBMITTED', itemId: '81' }
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
await expect(updateMeasurementOuterDimensions('10001', '7001', '81', payload)).resolves.toEqual(saved)
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/7001/measurement/items/81/outer-dimensions',
payload,
{ params: { tenantId: '10001' } },
)
})
it('downloads the current measurement image using the scoped file route', async () => {
const image = new Blob(['image-data'], { type: 'image/jpeg' })
vi.mocked(http.get).mockResolvedValue(image)
+31
View File
@@ -7,6 +7,10 @@ import type {
ConfirmedPackageCountRequest,
SaveInboundMeasurementDraftRequest,
SubmitInboundMeasurementRequest,
UpdateMeasurementCalculationStrategyRequest,
MeasurementCalculationStrategyUpdated,
UpdateMeasurementOuterDimensionsRequest,
MeasurementOuterDimensionsUpdated,
ManualCartonScanRequest,
} from './types'
@@ -57,6 +61,33 @@ export async function saveInboundMeasurementDraft(
return unwrapApiResponse(response)
}
export async function updateMeasurementCalculationStrategy(
customerId: string,
inboundId: string,
payload: UpdateMeasurementCalculationStrategyRequest,
) {
const response = await http.put<ApiEnvelope<MeasurementCalculationStrategyUpdated>, ApiEnvelope<MeasurementCalculationStrategyUpdated>>(
`${measurementPath(inboundId)}/calculation-strategy`,
payload,
{ params: { tenantId: customerId } },
)
return unwrapApiResponse(response)
}
export async function updateMeasurementOuterDimensions(
customerId: string,
inboundId: string,
itemId: string,
payload: UpdateMeasurementOuterDimensionsRequest,
) {
const response = await http.put<ApiEnvelope<MeasurementOuterDimensionsUpdated>, ApiEnvelope<MeasurementOuterDimensionsUpdated>>(
`${measurementPath(inboundId)}/items/${segment(itemId)}/outer-dimensions`,
payload,
{ params: { tenantId: customerId } },
)
return unwrapApiResponse(response)
}
export async function submitInboundMeasurement(
customerId: string,
inboundId: string,
+33
View File
@@ -1,6 +1,7 @@
export type MeasurementIdentifier = string
export type InboundMeasurementProcessStatus =
| 'PREPARING'
| 'WAIT_MEASURE'
| 'MEASURING'
| 'MEASURE_PENDING'
@@ -8,7 +9,12 @@ export type InboundMeasurementProcessStatus =
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'WAIT_PALLET'
| 'WAIT_PALLETIZE'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
| 'DELIVERED'
| 'VOIDED'
export interface InboundMeasurementItem {
@@ -97,6 +103,8 @@ export interface InboundMeasurementDetail {
companyName: string
channelCode: string | null
channelName: string | null
fulfillmentType?: string | null
expectedPalletCount?: number | null
processStatus: InboundMeasurementProcessStatus
measureReviewStatus: string | null
recommendedPalletType: string | null
@@ -148,6 +156,7 @@ export interface SaveInboundMeasurementDraftRequest {
measurementId: MeasurementIdentifier | null
version: number
actualGoodsName: string | null
expectedPalletCount?: number | null
items: SaveInboundMeasurementItemRequest[]
}
@@ -157,6 +166,30 @@ export interface InboundMeasurementDraftSaved {
status: string
}
export interface UpdateMeasurementCalculationStrategyRequest {
measurementId: MeasurementIdentifier
version: number
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
chargeRuleId?: MeasurementIdentifier | null
}
export interface MeasurementCalculationStrategyUpdated extends InboundMeasurementDraftSaved {
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
chargeRuleId: MeasurementIdentifier | null
}
export interface UpdateMeasurementOuterDimensionsRequest {
measurementId: MeasurementIdentifier
version: number
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
}
export interface MeasurementOuterDimensionsUpdated extends InboundMeasurementDraftSaved {
itemId: MeasurementIdentifier
}
export interface ConfirmedPackageCountRequest {
measurementVersion: number
confirmedPackageCount: number
@@ -79,8 +79,8 @@ describe('InboundOrdersView', () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({
records: [
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', internalCode: null, warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', internalCode: null, warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
],
page: 1, size: 200, total: 2, pages: 1,
})
+1
View File
@@ -3,6 +3,7 @@ import { inboundHistoryActionLabel, inboundOrderStatusLabels, inboundProcessStat
describe('inbound status presentation', () => {
it('translates process and order statuses', () => {
expect(inboundStatusLabel('PREPARING', inboundProcessStatusLabels)).toBe('订单待审核(可先测量)')
expect(inboundStatusLabel('MEASURE_PENDING', inboundProcessStatusLabels)).toBe('待测量审核')
expect(inboundStatusLabel('WAIT_SPLIT', inboundOrderStatusLabels)).toBe('待分柜')
})
+1 -1
View File
@@ -1,5 +1,5 @@
export const inboundProcessStatusLabels: Record<string, string> = {
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待测量审核', MEASURE_REJECTED: '测量已驳回',
PREPARING: '订单待审核(可先测量)', WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待测量审核', MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用处理中', INBOUNDING: '入库处理中', WAIT_PALLETIZE: '待一件代发组托', WAIT_SPLIT: '待分柜', VOIDED: '已作废',
}
@@ -447,7 +447,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5]])
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5, true]])
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.text()).toContain('实测毛重')
expect(wrapper.text()).toContain('货物重量')
@@ -8,7 +8,7 @@ describe('WeightOcrPanel contract', () => {
it('uses customer language at the page boundary while retaining a compatibility input', () => {
expect(source).toContain('reviewMode?: boolean')
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]')
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]')
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
expect(template).not.toContain('租户')
@@ -46,7 +46,7 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain('<div><dt>库位</dt><dd')
expect(source).not.toContain('workspace.palletLocationGuide.slots')
expect(source).toContain('对应库位仍为本票保留,不会释放给其他订单')
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
expect(source).toContain("workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true")
})
it('matches backend image limits and keeps technical OCR data out of the list', () => {
@@ -79,7 +79,7 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain("platform:firstleg:inbound:measure:view")
expect(source).toContain("platform:firstleg:inbound:weight:edit")
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
expect(source).toContain("new Set(['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
expect(source).toContain('&& canEdit.value')
expect(source).toContain('&& editable.value')
expect(source).toContain('cancelPalletOptionsLoad()')
@@ -41,7 +41,7 @@ const props = withDefaults(defineProps<{
})
const emit = defineEmits<{
changed: []
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
}>()
const access = useAccessPreviewStore()
@@ -68,7 +68,7 @@ let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const editableStatuses = new Set(['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
const maximumSafeWeight = 9_000_000_000_000
const customerId = computed(() => props.customerId || props.tenantId || '')
@@ -213,7 +213,12 @@ function buildAdjustmentReason(row: WeightResultResource) {
}
function emitWorkspaceChanged() {
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)
emit(
'summaryChanged',
workspace.value?.actualGrossWeightKg ?? null,
workspace.value?.actualNetWeightKg ?? null,
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
)
emit('changed')
}
@@ -6,6 +6,8 @@ import {
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
preparePalletLabels,
printPalletLabels,
updatePreloadTransport,
updateLoadingItemWarehouseRemark,
} from './platformLoadingLists'
@@ -158,4 +160,18 @@ describe('platform loading lists API', () => {
})
})
it('prepares and prints pallet labels by FBA warehouse', async () => {
const post = vi.spyOn(http, 'post')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { planId: 10, planNo: 'LP-1', containerNo: 'MSCU6639870', totalCount: 2, groups: [{ destinationWarehouseCode: 'ONT8', destinationWarehouseInternalCode: '154', destinationWarehouseName: 'ONT8', totalCount: 2, labels: [{ labelId: 1, itemId: 2, accountId: 3, inboundId: 4, inboundCode: 'RK-1', containerNo: 'MSCU6639870', destinationWarehouseCode: 'ONT8', destinationWarehouseInternalCode: '154', destinationWarehouseName: 'ONT8', palletIndex: 1, palletCode: 'MSCU6639870-154-001' }] }] } })
.mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
const manifest = await preparePalletLabels('10', '20')
const pdf = await printPalletLabels('10', '20', 'ONT8', 1)
expect(manifest).toMatchObject({ planId: '10', groups: [{ labels: [{ labelId: '1', itemId: '2', accountId: '3', inboundId: '4' }] }] })
expect(pdf.type).toBe('application/pdf')
expect(post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/loading-plans/10/pallet-labels/prepare', null, { params: { warehouseId: '20' } })
expect(post).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/loading-plans/10/pallet-labels/print', { destinationWarehouseCode: 'ONT8', count: 1 }, { params: { warehouseId: '20' }, responseType: 'blob', timeout: 60_000 })
})
})
@@ -263,6 +263,36 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
}
}
export interface PalletLabel {
labelId: string
itemId: string
accountId: string
inboundId: string
inboundCode: string
containerNo: string
destinationWarehouseCode: string
destinationWarehouseInternalCode: string
destinationWarehouseName: string | null
palletIndex: number
palletCode: string
}
export interface PalletLabelGroup {
destinationWarehouseCode: string
destinationWarehouseInternalCode: string
destinationWarehouseName: string | null
totalCount: number
labels: PalletLabel[]
}
export interface PalletLabelManifest {
planId: string
planNo: string
containerNo: string
totalCount: number
groups: PalletLabelGroup[]
}
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
return {
records: (raw.records || []).map(normalize),
@@ -360,3 +390,26 @@ export async function getConfirmedLoadingList(planId: string, warehouseId: strin
histories: (result.histories || []).map(normalizeHistory),
}
}
export async function preparePalletLabels(planId: string, warehouseId: string) {
const response = await http.post<ApiEnvelope<PalletLabelManifest>, ApiEnvelope<PalletLabelManifest>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/pallet-labels/prepare`, null, { params: { warehouseId } },
)
const result = unwrapApiResponse(response)
return {
...result,
planId: requiredId(result.planId),
groups: (result.groups || []).map((group) => ({
...group,
labels: (group.labels || []).map((label) => ({ ...label, labelId: requiredId(label.labelId), itemId: requiredId(label.itemId), accountId: requiredId(label.accountId), inboundId: requiredId(label.inboundId) })),
})),
}
}
export function printPalletLabels(planId: string, warehouseId: string, destinationWarehouseCode?: string, count?: number) {
return http.post<Blob, Blob>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/pallet-labels/print`,
{ destinationWarehouseCode: destinationWarehouseCode || null, count: count || null },
{ params: { warehouseId }, responseType: 'blob', timeout: 60_000 },
)
}
@@ -6,6 +6,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
@@ -16,6 +17,7 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
embedded?: boolean
canEditTransport?: boolean
transportNote?: string
confirmSaving?: boolean
@@ -146,13 +148,14 @@ function formatNumber(value?: number | null, digits = 3) {
<template>
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
<section class="detail-overview" aria-label="清单概览">
<header class="detail-page-header">
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div class="detail-page-header__identity">
<div v-if="!embedded" class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<PalletLabelPrintDialog v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
@@ -237,6 +240,7 @@ function formatNumber(value?: number | null, digits = 3) {
.detail-layout { display: grid; min-width: 0; }
.detail-overview { min-width: 0; }
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 8px 0 12px; }
.detail-page-header--embedded { grid-template-columns: minmax(0, 1fr) auto; padding-top: 0; }
.detail-page-header__back { margin-right: 2px; }
.detail-page-header__identity { min-width: 0; }
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
const preparePalletLabels = vi.fn()
vi.mock('../api/platformLoadingLists', () => ({
preparePalletLabels: (...args: unknown[]) => preparePalletLabels(...args),
printPalletLabels: vi.fn(),
}))
describe('PalletLabelPrintDialog', () => {
beforeEach(() => {
preparePalletLabels.mockReset().mockResolvedValue({
planId: '10', planNo: 'LP-1', containerNo: 'HLCU1723867', totalCount: 2,
groups: [{
destinationWarehouseCode: 'ACY2', destinationWarehouseInternalCode: '101',
destinationWarehouseName: 'ACY2', totalCount: 2,
labels: [
{ palletCode: 'HLCU1723867-101-001' },
{ palletCode: 'HLCU1723867-101-002' },
],
}],
})
})
it('shows the server-generated internal code rule and both warehouse codes', async () => {
const wrapper = mount(PalletLabelPrintDialog, {
props: { planId: '10', warehouseId: '20' },
global: {
stubs: {
AppDialog: { template: '<section><slot /></section>' },
LoadErrorState: true,
Printer: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-alert': { props: ['title'], template: '<p>{{ title }}</p>' },
'el-radio-group': { template: '<div><slot /></div>' },
'el-radio': { template: '<span><slot /></span>' },
'el-select': { template: '<div><slot /></div>' },
'el-option': true,
'el-input-number': true,
'el-table': { props: ['data'], template: '<div>{{ data }}</div>' },
'el-table-column': true,
},
},
})
await wrapper.get('button').trigger('click')
await nextTick()
await nextTick()
expect(preparePalletLabels).toHaveBeenCalledWith('10', '20')
expect(wrapper.text()).toContain('HLCU1723867-FBA地址内部编号-三位序号')
expect(wrapper.text()).toContain('ACY2')
expect(wrapper.text()).toContain('101')
expect(wrapper.text()).toContain('HLCU1723867-101-001')
})
})
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Printer } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { preparePalletLabels, printPalletLabels, type PalletLabelManifest } from '../api/platformLoadingLists'
const props = defineProps<{ planId: string; warehouseId: string; disabled?: boolean }>()
const open = ref(false)
const loading = ref(false)
const printing = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const manifest = ref<PalletLabelManifest | null>(null)
const scope = ref<'ALL' | 'WAREHOUSE'>('ALL')
const warehouseCode = ref('')
const count = ref(1)
const selectedGroup = computed(() => manifest.value?.groups.find((group) => group.destinationWarehouseCode === warehouseCode.value) || null)
const disabledConfirm = computed(() => !manifest.value?.totalCount || (scope.value === 'WAREHOUSE' && (!selectedGroup.value || count.value < 1 || count.value > selectedGroup.value.totalCount)))
async function show() {
open.value = true; loading.value = true; error.value = null; manifest.value = null
try {
manifest.value = await preparePalletLabels(props.planId, props.warehouseId)
warehouseCode.value = manifest.value.groups[0]?.destinationWarehouseCode || ''
count.value = manifest.value.groups[0]?.totalCount || 1
} catch (cause) { error.value = getApiErrorDetails(cause, '托盘码清单加载失败') }
finally { loading.value = false }
}
function onWarehouseChange() { count.value = selectedGroup.value?.totalCount || 1 }
async function print() {
if (!manifest.value) return
printing.value = true; error.value = null
try {
const blob = await printPalletLabels(props.planId, props.warehouseId,
scope.value === 'WAREHOUSE' ? warehouseCode.value : undefined,
scope.value === 'WAREHOUSE' ? count.value : undefined)
const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
anchor.href = url; anchor.download = `${manifest.value.containerNo}-托盘码.pdf`; anchor.click(); URL.revokeObjectURL(url)
ElMessage.success(`已生成 ${scope.value === 'ALL' ? manifest.value.totalCount : count.value} 张托盘码`)
} catch (cause) { error.value = getApiErrorDetails(cause, '托盘码打印失败') }
finally { printing.value = false }
}
</script>
<template>
<el-button :disabled="disabled" @click="show"><Printer :size="16" />打印托盘码</el-button>
<AppDialog v-model="open" title="散货托盘码" width="min(760px, calc(100vw - 28px))" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
<template v-if="manifest">
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
<el-radio-group v-model="scope" class="print-scope"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
<div v-if="scope === 'WAREHOUSE'" class="print-options"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
<el-table :data="manifest.groups" border max-height="300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
</template>
</AppDialog>
</template>
<style scoped>
.print-scope { display:flex; margin:18px 0 12px; }
.print-options { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.print-options .el-select { width:240px; }
</style>
+1
View File
@@ -10,3 +10,4 @@ export { loadingListRoutes } from './routes'
export { loadingListStatusLabel } from './status'
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
export { default as PalletLabelPrintDialog } from './components/PalletLabelPrintDialog.vue'
@@ -26,7 +26,7 @@ describe('ocean bookings api', () => {
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 }
const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HC', page: 1, size: 200 }
const result = await listOceanBookingLinkCandidates(query)
+1 -1
View File
@@ -99,7 +99,7 @@ export interface OceanBookingLinkCandidate {
export interface OceanBookingLinkCandidatePage { records: OceanBookingLinkCandidate[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingLinkCandidateQuery {
warehouseId: string
channelCode: string
channelGroupCode: string
containerType: string
page: number
size: number
@@ -5,13 +5,13 @@
当前已接入:
- 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。
- 采购审核 `review`:采购详情页只对待审核订单和独立审核权限开放,提交订单版本、通过/拒绝决定、稳定幂等键和可选备注。
- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。
- 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。
- canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。
明确不接入:
- 采购审核 `review`:后端存在,但当前页面原型与本专项范围排除。
- 采购出库不提供独立 `/outbounds` 端点;采购编辑通过出库明细维护出库量并派生待出库、部分出库、全部出库状态,不直接操作海外仓 PDA 可用库存。
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
- 海外出库、海外派送和 Merchant API。
@@ -7,6 +7,7 @@
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 |
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 详情页顶部操作 + 居中确认弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;提交版本、通过/拒绝决定、稳定幂等键及可选备注 |
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
@@ -17,4 +18,4 @@
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 |
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
@@ -6,7 +6,7 @@ vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas }))
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase,
updateUsTruckOrder,
} from './platformOverseasBusiness'
import type { UpdateLocalTransportOrderRequest } from '../types'
@@ -57,6 +57,16 @@ describe('platform overseas business API adapter', () => {
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } })
})
it('submits procurement review with version and idempotency data', async () => {
const detail = { summary: { ...purchaseSummary, status: 'APPROVED', version: 5 }, histories: [] }
overseas.post.mockResolvedValue(envelope(detail))
const payload = { version: 4, decision: 'APPROVE' as const, idempotencyKey: 'review-key', remark: '资料齐全' }
const result = await reviewOverseasPurchase('OP/1', '10001', payload)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/review', payload, { params: { customerId: '10001' } })
expect(result.status).toBe('APPROVED')
expect(result.version).toBe(5)
})
it('supports platform truck list/detail/create/update without status endpoints', async () => {
const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
@@ -6,7 +6,7 @@ import type {
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail,
OverseasPurchaseFile, OverseasPurchaseFee,
OverseasPurchaseFile, OverseasPurchaseFee, ReviewOverseasPurchaseRequest,
} from '../types'
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
@@ -76,6 +76,10 @@ export async function updateOverseasPurchase(orderNo: string, customerId: string
const response = await overseasHttp.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
}
export async function reviewOverseasPurchase(orderNo: string, customerId: string, payload: ReviewOverseasPurchaseRequest) {
const response = await overseasHttp.post<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
return procurementDetail(unwrapApiResponse(response))
}
export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) {
const body = new FormData(); body.append('file', file)
const response = await overseasHttp.post<ApiEnvelope<OverseasPurchaseFile>, ApiEnvelope<OverseasPurchaseFile>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } })
@@ -1,4 +1,6 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,11 +8,12 @@ import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
} from '../api/platformOverseasBusiness'
import PurchaseOrdersView from './PurchaseOrdersView.vue'
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
@@ -18,11 +21,14 @@ import PurchaseOrderEditView from './PurchaseOrderEditView.vue'
import TransportOrdersView from './TransportOrdersView.vue'
import TransportOrderFormView from './TransportOrderFormView.vue'
const purchaseListSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/pages/PurchaseOrdersView.vue'), 'utf8')
const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/overseas-business/pages/PurchaseOrderDetailView.vue'), 'utf8')
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), reviewOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
}))
@@ -43,7 +49,7 @@ const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', d
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
@@ -51,7 +57,7 @@ describe('overseas business mounted pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }); vi.mocked(reviewOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, status: 'APPROVED', version: 4 })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
@@ -59,9 +65,12 @@ describe('overseas business mounted pages', () => {
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
it('reviews a pending procurement from its detail page', async () => { const pending = { ...purchase, status: 'PENDING_REVIEW' as const, inboundQuantity: 0, inventoryQuantity: 0, version: 2 }; routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(getOverseasPurchase).mockResolvedValue(pending); vi.mocked(reviewOverseasPurchase).mockResolvedValue({ ...pending, status: 'APPROVED', version: 3 }); const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('通过'))?.trigger('click'); await wrapper.vm.$nextTick(); const reviewDialog = wrapper.findAllComponents(AppDialog)[0]; expect(reviewDialog.props('modelValue')).toBe(true); reviewDialog.vm.$emit('confirm'); await flushPromises(); expect(reviewOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 2, decision: 'APPROVE', idempotencyKey: expect.any(String) })); expect(wrapper.text()).toContain('已通过'); expect(wrapper.find('.header-actions').text()).toBe('返回') })
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onActivated, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Upload } from '@lucide/vue'
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -11,7 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createOverseasPurchaseFee, getOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
@@ -29,6 +29,7 @@ const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
const historyColumns: DataTableColumn[] = [
@@ -45,6 +46,29 @@ function display(value: unknown) { return value === null || value === undefined
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function openReview(decision: 'APPROVE' | 'REJECT') {
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
}
function closeReview() {
if (reviewing.value) return
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
}
async function submitReview() {
if (!detail.value) return
reviewing.value = true
try {
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
version: detail.value.version,
decision: reviewDecision.value,
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
remark: reviewRemark.value.trim() || undefined,
})
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
finally { reviewing.value = false }
}
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
@@ -64,12 +88,14 @@ async function load() {
loading.value = true
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
}
let activatedOnce = false
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><div class="header-actions"><template v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></template><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></div></template>
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="detail-surface surface-panel">
<div class="detail-grid">
@@ -83,9 +109,9 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout><AppDialog v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</DetailPageLayout><AppDialog v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><el-form class="review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
</template>
<style scoped>
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading,.section-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; }.section-heading span { color:var(--cc-muted); font-size:12px; }:deep(.el-input-number){width:100%}
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.header-actions { display:flex; align-items:center; gap:10px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading,.section-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; }.section-heading span { color:var(--cc-muted); font-size:12px; }.review-form { margin-top:16px; }:deep(.el-input-number){width:100%}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -54,7 +54,9 @@ async function load() {
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
let activatedOnce = false
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
</script>
<template>
+6
View File
@@ -39,6 +39,12 @@ export interface UpdateOverseasPurchaseRequest {
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
outboundDetails?: OverseasPurchaseOutboundDetail[]
}
export interface ReviewOverseasPurchaseRequest {
version: number
decision: 'APPROVE' | 'REJECT'
idempotencyKey: string
remark?: string
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
+34
View File
@@ -0,0 +1,34 @@
# Admin 海外仓模块指引
## 读取与路径基准
- 本文件适用于 `src/modules/overseas-warehouse/`,路径默认相对此目录;其他模块以 `../` 开头。应用公共规则见 `../../../AGENTS.md`。
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;页面是否开放以正式路由、权限和测试为准。后台接口与状态进入 `../../../../../baiyue-firstleg/lastleg-service/AGENTS.md`,不扩大扫描其他业务模块。
- 请求统一使用 `../../shared/api/overseasHttp.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。
## 页面与业务快速索引
退货库存与退货箱号:`pages/ReturnInventoryManagementView.vue`、`api/returnInventory.ts`,由 `routes.ts` 的 `return-inventory/return-cartons` 分别以 `mode=inventory/cartons` 进入;只消费 Lastleg `returnreceipt/` 的平台库存、箱号、流水和标签接口。退货出库从 `pages/OutboundsView.vue` 进入,区别于 `pages/ExceptionOrdersView.vue` 的派送异常退回。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `pages/PalletsView.vue`、`pages/CartonsView.vue`、`pages/ProductsView.vue`、`pages/RelabelOrdersView.vue` 及详情页 | `api/pallets.ts`、`api/relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `routes.ts` 为准 |
## 履约硬边界
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 验证
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
@@ -10,7 +10,7 @@
| 尾程整柜转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert-container` | body:`sourceContainerBookingId:string`;只调用一次柜级原子接口,不在前端循环逐票;返回一个计划数组,每个尾程订单/入仓码对应一个独立计划 | `...:inbound-order:create`;成功按接口返回计划数提示并刷新原待分配订单列表,不用当前订单页行数校验整柜总数;错误保留 code/requestId/traceId |
| 柜级计划进度/详情 | `GET /platform/v1/last-leg/orders/container-summaries`;`GET /container-summaries/{sourceContainerBookingId}` | summary:`sourceContainerBookingId,containerNo?,totalOrderCount,inboundCodeCount,missingInboundCodeCount,planCount,plannedInboundCodeCount,pendingPlanCount,plannedArrivalAt?,allPlansReady,blockingReason?`;list query:`page,size,containerNo?,createdFrom?,createdTo?`;detail:`summary,orders[]`,订单与计划 ID 均为字符串 | 独立“柜子”Tab 消费服务端全柜分页,默认按计划到仓升序且 null 最后;详情抽屉显示柜内全部订单、入仓码和独立计划状态;同一摘要也供按柜生成选择框,不从订单分页本地拼柜 |
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户;每行返回计划级 `assigned/mergeAllowed/urgent` | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串;合托展示为可选提示,不作为完成门禁 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码;`allocations[]` 直接返回实际 `palletNo`、`palletOwnerPlanNo`、`palletOwnedByCurrentPlan` | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化;跨计划合托按分配事实展示真实托盘码、所属计划和本单箱数,不把外部托盘计入当前计划自有托盘数 |
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
@@ -38,6 +38,12 @@ describe('dispatch overseas API', () => {
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('requests the generated BOL as a PDF blob', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue(new Blob(['%PDF-test']))
await getDispatchBolPdf('PC/1')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
})
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
@@ -87,6 +87,18 @@ export interface DispatchSummaryDto {
partialCompletionReason?: string | null
createdAt?: string | null
items?: DispatchItemDto[]
bolLines?: DispatchBolLineDto[]
}
export interface DispatchBolLineDto {
containerNo?: string | null
palletNo?: string | null
fbaWarehouseCode?: string | null
destinationWarehouseName?: string | null
warehouseNo?: string | null
fbaShipmentId?: string | null
poReferenceNo?: string | null
packageCount: number
}
export interface UpdateDispatchPayload {
@@ -135,6 +147,13 @@ export async function getDispatch(dispatchNo: string) {
return unwrapApiResponse(response)
}
export async function getDispatchBolPdf(dispatchNo: string) {
return overseasHttp.get<Blob, Blob>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`,
{ responseType: 'blob' },
)
}
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
const response = await overseasHttp.put<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
@@ -228,6 +247,7 @@ export interface TransferDeliveryPayload {
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
palletCartonMarks?: Record<string, string[]>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
@@ -47,6 +47,9 @@ export interface InboundPlanDetailDto {
planNo: string
customerId: string
sourceOrderId: string
sourceOrderNo?: string | null
customerName?: string | null
orderType?: string | null
containerNo?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
@@ -56,9 +59,12 @@ export interface InboundPlanDetailDto {
totalVolumeCbm: number
expectedPackageCount?: number
pickedPackageCount?: number
createdAt?: string | null
updatedAt?: string | null
remark?: string | null
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
allocations: Array<{ lineId: string; palletId: string; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
}
export async function listInboundPlans(query: InboundPlanQuery) {
@@ -131,7 +131,7 @@ describe('last-mile order overseas API', () => {
const record = {
sourceContainerBookingId: '2092873463568597001', containerNo: 'CONT-15', totalOrderCount: 15,
inboundCodeCount: 13, missingInboundCodeCount: 2, planCount: 10, plannedInboundCodeCount: 10,
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在2票缺少入仓码',
pendingPlanCount: 5, resourcePendingPlanCount: 0, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在2票缺少入仓码',
}
vi.mocked(overseasHttp.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [record], page: '2', size: '10', total: '21', pages: '3' },
@@ -61,6 +61,7 @@ export interface LastMileOrderSummaryDto {
transferWarehouseCode?: string | null
fbaWarehouseCode?: string | null
containerNo?: string | null
containerNode?: string | null
abnormal?: boolean | null
detained?: boolean | null
holdChargeable?: boolean | null
@@ -183,6 +184,7 @@ export interface ContainerPlanProgressSummaryDto {
planCount: number
plannedInboundCodeCount: number
pendingPlanCount: number
resourcePendingPlanCount: number
plannedArrivalAt: string | null
allPlansReady: boolean
blockingReason: string | null
@@ -212,7 +214,7 @@ export interface ConvertContainerInboundPlanPayload {
snapshotHash: string
}
export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED'
export type ContainerInboundPlanPreviewReadiness = 'READY' | 'RESOURCE_PENDING' | 'PLANNED' | 'BLOCKED'
export interface ContainerInboundPlanPreviewOrderDto {
customerId: string
@@ -225,9 +227,13 @@ export interface ContainerInboundPlanPreviewOrderDto {
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
fbaWarehouseCode: string | null
packageCount: number
volumeCbm: number
suggestedPalletCount: number | null
sourcePalletCount: number
targetPalletCount: number
supplementPalletCount: number
plannedPalletCount: number | null
existingPlanNo: string | null
planStatus: string | null
@@ -244,6 +250,8 @@ export interface ContainerInboundPlanPreviewGroupDto {
orderCount: number
pendingOrderCount: number
pendingPalletCount: number
sourcePalletCount: number
supplementPalletCount: number
existingPlannedPalletCount: number
suggestedPalletCount: number
plannedPalletCount: number
@@ -263,6 +271,8 @@ export interface ContainerInboundPlanPreviewDto {
blockedOrderCount: number
pendingOrderCount: number
pendingPalletCount: number
sourcePalletCount: number
supplementPalletCount: number
existingPlannedPalletCount: number
totalSuggestedPalletCount: number
totalPlannedPalletCount: number
@@ -114,6 +114,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
transferWarehouse: text(dto.transferWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode),
containerNo: text(dto.containerNo),
containerNode: text(dto.containerNode),
abnormal: yesNo(dto.abnormal),
held: yesNo(dto.detained),
detained: Boolean(dto.detained),
@@ -10,7 +10,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
const widths: Record<string, number> = {
'LastMileOrdersView.vue': 230,
'InboundOrdersView.vue': 300,
'DispatchesView.vue': 270,
'DispatchesView.vue': 380,
'OutboundsView.vue': 180,
'DeliveryOrdersView.vue': 300,
'RelabelOrdersView.vue': 180,
@@ -4,12 +4,12 @@ import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { cancelDispatch, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), updateDispatch: vi.fn(), removeDispatchItem: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -47,6 +47,7 @@ describe('DispatchesView', () => {
page: 1, size: 20, total: 1, pages: 1,
})
vi.mocked(updateDispatch).mockReset()
vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
@@ -85,6 +86,22 @@ describe('DispatchesView', () => {
expect(wrapper.html()).toContain('190000000000000099')
})
it('opens the server-generated BOL PDF with one request', async () => {
const pdfWindow = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(pdfWindow as unknown as Window)
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:dispatch-bol'), revokeObjectURL: vi.fn() })
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchBolPdf).not.toHaveBeenCalled()
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text().trim() === '打印BOL')!.trigger('click')
await flushPromises()
expect(getDispatchBolPdf).toHaveBeenCalledOnce()
expect(getDispatchBolPdf).toHaveBeenCalledWith('PC-1')
expect(pdfWindow.location.href).toBe('blob:dispatch-bol')
})
it('documents the backend feature flag required by a missing endpoint', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('请确认海外尾程排车接口已发布')
@@ -100,8 +117,8 @@ describe('DispatchesView', () => {
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
expect(source).toContain('未生成出库单前可移除托盘')
expect(source).toContain('removeDispatchItem')
expect(source).not.toContain('托盘箱唛确认')
expect(source).not.toContain('palletCartonMarks')
expect(source).toContain('托盘箱唛确认')
expect(source).toContain('palletCartonMarks')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, MoreHorizontal, Pencil, Trash2, Truck, XCircle } from '@lucide/vue'
import { Eye, MoreHorizontal, Pencil, Printer, Trash2, Truck, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -42,6 +42,7 @@ const editRow = ref<DispatchListItem | null>(null)
const editSaving = ref(false)
const editItems = ref<DispatchItemDto[]>([])
const removingItemId = ref('')
const bolLoadingNo = ref('')
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({
vehiclePlate: '',
@@ -72,6 +73,7 @@ const transferForm = reactive({
deliveryFee: undefined as number | undefined,
loadingFee: undefined as number | undefined,
appointmentFee: undefined as number | undefined,
cartonMarksJson: '',
remark: '',
})
@@ -152,20 +154,8 @@ const fields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{
key: 'actions',
label: '操作',
width: 270,
fixed: 'left',
slotName: 'actions',
},
{
key: 'dispatchNo',
prop: 'dispatchNo',
label: '排车单号',
minWidth: 170,
fixed: 'left',
},
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
@@ -332,6 +322,24 @@ function openDetail(row: DispatchListItem) {
params: { dispatchNo: row.dispatchNo },
})
}
async function openBol(row: DispatchListItem) {
if (bolLoadingNo.value) return
const pdfWindow = window.open('', '_blank')
if (!pdfWindow) { ElMessage.warning('浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口'); return }
bolLoadingNo.value = row.dispatchNo
mutationError.value = null
try {
const pdf = await getDispatchBolPdf(row.dispatchNo)
const url = URL.createObjectURL(pdf)
pdfWindow.location.href = url
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
pdfWindow.close()
mutationError.value = getApiErrorDetails(cause, 'BOL PDF 生成失败,请重试')
} finally {
bolLoadingNo.value = ''
}
}
function isPendingDeparture(row: DispatchListItem) {
return row.statusCode === 'PENDING_DEPARTURE'
}
@@ -384,6 +392,7 @@ function openTransfer(row: DispatchListItem) {
deliveryFee: undefined,
loadingFee: undefined,
appointmentFee: undefined,
cartonMarksJson: '',
remark: '',
})
mutationError.value = null
@@ -503,6 +512,10 @@ async function submitTransfer() {
amount: Number(amount),
remark: feeName,
}))
let palletCartonMarks: Record<string, string[]> | undefined
if (transferForm.cartonMarksJson.trim()) {
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
}
transferSaving.value = true
try {
await transferDispatchToDelivery(transferRow.value.dispatchNo, {
@@ -522,6 +535,7 @@ async function submitTransfer() {
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
palletCartonMarks,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
@@ -537,91 +551,17 @@ onMounted(load)
</script>
<template>
<AdminPage
><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state
><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"
><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState
></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
preference-key="overseas-dispatches"
row-key="id"
empty-text="暂无排车记录"
@update:page="load()"
@update:page-size="page = 1; load()"
>
<template #actions="{ row }"
><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button
><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"
><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button
><template #dropdown
><el-dropdown-menu
><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu
></template
></el-dropdown
></template
>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
</section>
</ListPageLayout></AdminPage
><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"
><el-form label-position="top"
><h3>派送信息</h3>
<div class="transfer-grid">
<el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item
><el-form-item label="派送方式" required
><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item
><el-form-item label="提货方式" required
><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item label="装车类型" required
><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item
><el-form-item label="车辆类型"
><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item label="车辆大小"
><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item label="预约账号"
><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item>
</div>
<h3>费用信息(USD)</h3>
<div class="transfer-grid">
<el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form
></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(1000px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"
><el-alert :title="`当前状态:${editRow?.status || '—'};未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form label-position="top"
><div class="edit-grid">
<el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item
><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item
><el-form-item label="FBA预约状态"
><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item
><el-form-item label="车辆预约状态"
><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select
></el-form-item></div
></el-form>
<h3>已排车托盘</h3>
<el-table :data="editItems" border max-height="280" empty-text="暂无托盘"
><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /><el-table-column label="操作" width="90"
><template #default="{ row }"
><el-button text type="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)"><Trash2 :size="15" />移除</el-button></template
></el-table-column
></el-table
></AppDialog
>
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button size="small" plain :loading="bolLoadingNo === (row as DispatchListItem).dispatchNo" @click="openBol(row as DispatchListItem)"><Printer :size="15" />打印BOL</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {&quot;PALLET-001&quot;:[&quot;箱唛1&quot;,&quot;箱唛2&quot;]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button text type="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)"><Trash2 :size="15" />移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template>
<style scoped>
.transfer-grid,
@@ -7,11 +7,13 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter()
const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
const normalizedCode = String(code ?? '').trim()
@@ -21,11 +23,11 @@ function receivingWarehouse(code: unknown, name: unknown) {
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) }
const firstLine = computed(() => detail.value?.lines[0])
const fieldValues = computed<Record<string, string | number>>(() => ({
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: '—',
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo),
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark),
}))
const groups = [
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
@@ -36,15 +38,16 @@ const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 7
const palletColumns: DataTableColumn[] = [
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
{ key: 'palletOwner', prop: 'palletOwner', label: '托盘所属计划', minWidth: 190 },
{ key: 'packageCount', prop: 'packageCount', label: '对应箱数', width: 110, align: 'right' },
]
const palletRows = computed(() => (detail.value?.allocations || []).map((allocation, index) => {
const line = detail.value?.lines.find(item => item.id === allocation.lineId)
const pallet = detail.value?.pallets.find(item => item.id === allocation.palletId)
return {
id: `${allocation.lineId}-${allocation.palletId}-${index}`,
inboundCode: text(line?.inboundCode),
palletNo: text(pallet?.palletNo),
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
}
}))
@@ -53,7 +56,7 @@ function back() { void router.push({ name: 'overseas-inbound-orders' }) }
onMounted(load)
</script>
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="detail?.planNo || planNo" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</p><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细" /></section>
@@ -39,6 +39,15 @@ const feeForm = reactive({ itemCode: '', itemName: '', direction: 'RECEIVABLE' a
const feeItemOptions = [{ code: 'STORAGE_FEE', name: '仓租' }, { code: 'INBOUND_OPERATION', name: '入库操作费' }, { code: 'OUTBOUND_OPERATION', name: '出库操作费' }, { code: 'RELABEL_FEE', name: '换标费' }, { code: 'REBOX_FEE', name: '换箱费' }, { code: 'PALLET_FEE', name: '托盘费' }, { code: 'DELIVERY_FEE', name: '派送费' }, { code: 'TRANSFER_FEE', name: '转仓费' }, { code: 'EXCEPTION_FEE', name: '异常处理费' }, { code: 'OTHER', name: '其他' }]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function containerNodeLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({
DEPARTED_WAREHOUSE: '已离开国内仓库', AT_ORIGIN_PORT: '重箱已进入起运港',
LOADED_ON_VESSEL: '已装船', DEPARTED_ORIGIN_PORT: '船舶已离开起运港', IN_TRANSIT: '海运途中',
ARRIVED_DESTINATION_PORT: '船舶已到目的港', DISCHARGED_FROM_VESSEL: '已卸船、未清关',
PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜',
} as Record<string, string>)[code] || text(code)
}
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
@@ -70,6 +79,7 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
]
})
const planFields = computed<DetailField[]>(() => {

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