Compare commits
59
Commits
78f31fb9a3
...
ba31dd0563
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba31dd0563 | ||
|
|
1da976ecb6 | ||
|
|
bd12b0a594 | ||
|
|
89dc3bf671 | ||
|
|
8bc86c8567 | ||
|
|
497530d6a9 | ||
|
|
b9330a9674 | ||
|
|
ce399e4153 | ||
|
|
2334bd846a | ||
|
|
737dd41ff1 | ||
|
|
80a4b3ab5e | ||
|
|
3ea1e279c6 | ||
|
|
6d26634b1b | ||
|
|
b673e22cb7 | ||
|
|
e0b1334d2d | ||
|
|
385c5d9e9e | ||
|
|
6b7536b95c | ||
|
|
23e991722c | ||
|
|
f702a819fa | ||
|
|
d92a883f8a | ||
|
|
b7b9835bf9 | ||
|
|
2817ca1d3f | ||
|
|
950967f101 | ||
|
|
b6b91297ae | ||
|
|
781d25de08 | ||
|
|
14907551d7 | ||
|
|
219fd3009f | ||
|
|
5a165add2c | ||
|
|
b57ea2d628 | ||
|
|
27c4ec759c | ||
|
|
5f1a7f04f1 | ||
|
|
0ed913a114 | ||
|
|
4382351569 | ||
|
|
dc1cbfcf4a | ||
|
|
a2f1ecc6b9 | ||
|
|
568f2dea8a | ||
|
|
84a194db80 | ||
|
|
c3d69d74c2 | ||
|
|
33b79edd18 | ||
|
|
2460278cd9 | ||
|
|
60734cbb03 | ||
|
|
10fa397013 | ||
|
|
20e4da5bb5 | ||
|
|
e3b2552d8d | ||
|
|
ae7d8fb22e | ||
|
|
911c6acad2 | ||
|
|
8c9f4468af | ||
|
|
9b5b324fe8 | ||
|
|
6a038c89ca | ||
|
|
fc0226d4cb | ||
|
|
503a4bac89 | ||
|
|
57c987da88 | ||
|
|
8b862da084 | ||
|
|
f5a43c1cfe | ||
|
|
114def1c11 | ||
|
|
108ad5c7b5 | ||
|
|
ae01e066ca | ||
|
|
2fd9c5fcc7 | ||
|
|
a47a7e9767 |
@@ -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 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
+58
-6
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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[]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 客户资金账户
|
||||
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、已用授信、冻结授信和总可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信和总可用余额;操作列固定第一列。授信到期日必须晚于今天且不限制最远日期,到期日当天仍有效。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值、扣款和授信调整必须提交账户版本与幂等键;扣款及授信下限由服务端校验。页面只展示服务端余额、授信和流水结果,不在浏览器补算或回退静态数据。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,例如 {"PALLET-001":["箱唛1","箱唛2"]}" /><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
Reference in New Issue
Block a user