From 8910e885630b1ee2713c543be37d16c4ddb1b4fa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 19:57:42 +0800 Subject: [PATCH 01/16] feat: split domestic and overseas container workbenches --- AGENTS.md | 11 +- src/app/router/index.test.ts | 2 +- src/core/navigation/adminNavigation.test.ts | 16 +-- src/core/navigation/adminNavigation.ts | 4 +- .../api/platformContainerLoading.test.ts | 19 ++- .../api/platformContainerLoading.ts | 54 ++++++-- .../components/ContainerOverviewPanel.test.ts | 12 +- .../components/ContainerOverviewPanel.vue | 25 ++-- .../pages/ContainerDetailView.test.ts | 10 +- .../pages/ContainerDetailView.vue | 118 ++++++++++-------- .../pages/ContainersView.test.ts | 50 ++++---- .../pages/ContainersView.vue | 96 +++++++------- .../pages/DomesticContainerDetailView.vue | 5 + .../pages/DomesticContainersView.vue | 5 + .../pages/OverseasContainerDetailView.vue | 5 + .../pages/OverseasContainersView.vue | 5 + .../pages/SmartLoadingWorkbenchView.vue | 5 +- src/modules/container-loading/routes.ts | 30 +++-- src/modules/container-loading/types.ts | 62 ++++++++- .../components/LoadingListsView.test.ts | 6 +- src/modules/loading-lists/index.ts | 1 + .../PreloadPlanDetailView.lifecycle.test.ts | 10 +- .../pages/PreloadPlanDetailView.test.ts | 4 +- .../pages/PreloadPlanDetailView.vue | 5 +- src/modules/loading-lists/routes.test.ts | 4 +- src/modules/loading-lists/routes.ts | 20 --- 26 files changed, 365 insertions(+), 219 deletions(-) create mode 100644 src/modules/container-loading/pages/DomesticContainerDetailView.vue create mode 100644 src/modules/container-loading/pages/DomesticContainersView.vue create mode 100644 src/modules/container-loading/pages/OverseasContainerDetailView.vue create mode 100644 src/modules/container-loading/pages/OverseasContainersView.vue diff --git a/AGENTS.md b/AGENTS.md index af859e87..975b625d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,17 +44,18 @@ | 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 | | 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 | | 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 | -| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 | +| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | | 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` | | 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/carrier-rates/`、`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;账户余额、授信、充值审核和手工充扣只消费 Finance 权威结果。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `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`,承载模拟柜、订舱关联、智能装柜、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实来自服务端 `context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)`,前端不重算。两端统一使用 `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/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | -| 目的港码头拖车与还空预警 | 货柜列表/详情 `modules/container-loading/pages/ContainersView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于 `unloadingCompletedAt + 72h` 的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` | +| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端 `context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)` 的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | @@ -62,7 +63,7 @@ | 海外仓业务合作仓与通用资料 | 合作仓 `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/`。一件代发出库从服务端库存位置选择并携带幂等键;排车转派送按稳定 `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/`。派送退回先从服务端查询可关联入仓单,回访附件和派送完成/状态均走海外真实 API | +| 海外异常退回与客户回访 | `modules/overseas-warehouse/pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | API 为 `exceptionOrders.ts`、`customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API | 全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。 @@ -70,7 +71,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| -| 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `plannedArrivalAt` 升序、空值最后返回仍有待生成计划入仓码的柜。详情返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪;整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | +| 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询与修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;是否展示生成动作只消费 `allPlansReady/pendingPlanCount`,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/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`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts index b316b27c..e936bc4e 100644 --- a/src/app/router/index.test.ts +++ b/src/app/router/index.test.ts @@ -53,7 +53,7 @@ describe('admin router access metadata', () => { it('keeps smart loading as an order dialog while retaining the single-container deep link', () => { expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false) const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench') - expect(detail?.path).toBe('/container-loading/:containerId/smart-loading') + expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading') expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list') }) diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index e08d6b01..978b4c03 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -46,8 +46,8 @@ describe('adminNavigation', () => { expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ '订舱列表', '拖车列表', - '货柜列表', - '装柜清单', + '送柜报关', + '清关提柜', ]) expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ '尾程订单', @@ -92,10 +92,11 @@ describe('adminNavigation', () => { expect(paths).toContain('/downloads') expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine'])) expect(paths).toContain('/master-data/fee-items') - expect(paths).toContain('/container-loading') + expect(paths).toContain('/container-loading/domestic') + expect(paths).toContain('/container-loading/overseas') expect(paths).not.toContain('/container-loading/smart-loading') expect(paths).toContain('/loading-plans') - expect(paths).toContain('/loading-lists') + expect(paths).not.toContain('/loading-lists') expect(paths).not.toContain('/port-schedules') expect(paths).not.toContain('/customs') expect(paths).not.toContain('/outbound-dispatches') @@ -241,18 +242,19 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard']) }) - it('shows container list and downstream lists without a standalone smart-loading menu', () => { + it('shows split container workbenches without a standalone smart-loading menu', () => { const filtered = filterAdminNavigation(adminNavigation, new Set([ 'platform:firstleg:container:list', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list', + 'platform:firstleg:terminal-drayage:list', ])) expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', '/loading-plans', - '/container-loading', - '/loading-lists', + '/container-loading/domestic', + '/container-loading/overseas', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 2e7affd5..807bd4fd 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -102,8 +102,8 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, - { key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' }, - { key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' }, + { key: 'domestic-container-workbench', title: '送柜报关', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' }, + { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' }, ], }, { diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 4c0fea2e..579f2cf1 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -6,7 +6,7 @@ import { adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, - getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, + getContainerOverview, updateContainerPickupCode, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking, updateContainer, @@ -17,7 +17,7 @@ vi.mock('@/shared/api/http', () => ({ unwrapApiResponse: (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', pickupOrderDocumentStatus: 'MISSING', 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, 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', @@ -38,10 +38,10 @@ describe('platform container loading API', () => { }) it('normalizes paged container ids and numeric metadata', async () => { - vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [{ container, workbenchStage: 'PRE_LOADING', statusSummary: { loadingStatus: 'WAIT_LOADING' }, allowedActions: ['EDIT_CONTAINER'], actualFacts: {}, terminalDrayage: null }], page: '1', size: '20', total: '1', pages: '1' } }) const result = await listContainers({ page: 1, size: 20 }) - expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', 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 }) - expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } }) + expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: String(container.pickupCodeUpdatedBy), 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 }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/domestic', { params: { page: 1, size: 20, scope: 'ACTIVE' } }) }) it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => { @@ -65,7 +65,7 @@ describe('platform container loading API', () => { it('normalizes every long id in candidates and plans', async () => { vi.mocked(http.get) - .mockResolvedValueOnce({ code: '200', data: container }) + .mockResolvedValueOnce({ code: '200', data: { container, workbenchStage: 'PRE_LOADING', statusSummary: {}, allowedActions: [], actualFacts: {}, terminalDrayage: null } }) .mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', estimatedVolumeCbm: '2.5', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } }) expect((await getContainer('9')).id).toBe(String(container.id)) expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, estimatedVolumeCbm: 2.5, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 }) @@ -203,6 +203,13 @@ describe('platform container loading API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' }) }) + it('saves the PU numeric code as a string with the container version', async () => { + vi.mocked(http.put).mockResolvedValueOnce({ code: '200', data: { container: { id: 7, warehouseId: 8, pickupCode: '00123456', pickupCodeUpdatedBy: 9, version: 3 }, documents: [], exception: {}, exceptionHistory: [], timeline: [] } }) + const result = await updateContainerPickupCode('7', '00123456', 2) + expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7/pickup-code', { pickupCode: '00123456', expectedVersion: 2 }) + expect(result.container).toMatchObject({ pickupCode: '00123456', pickupCodeUpdatedBy: '9', version: 3 }) + }) + it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => { vi.mocked(http.get).mockResolvedValue(new Blob(['photo'])) const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download' diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index a9454c01..0967a8c5 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -25,6 +25,8 @@ import type { ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, + ContainerWorkbenchKind, + ContainerWorkbenchRow, } from '../types' const containerPath = '/platform/v1/containers' @@ -49,7 +51,7 @@ function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocu function overview(raw: ContainerOverview): ContainerOverview { return { ...raw, - container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), version: numberValue(raw.container.version) }, + container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), pickupCodeUpdatedBy: optionalId(raw.container.pickupCodeUpdatedBy), version: numberValue(raw.container.version) }, loadingPlanId: optionalId(raw.loadingPlanId), pda: raw.pda ? { ...raw.pda, taskId: id(raw.pda.taskId), loadingPlanId: id(raw.pda.loadingPlanId), @@ -87,6 +89,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource { shippingCompanyId: optionalId(raw.shippingCompanyId), terminalTruckingCompanyId: optionalId(raw.terminalTruckingCompanyId), emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion), + pickupCodeUpdatedBy: optionalId(raw.pickupCodeUpdatedBy), version: numberValue(raw.version), capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm), maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg), @@ -95,6 +98,30 @@ function container(raw: ContainerBookingResource): ContainerBookingResource { } } +function normalizeWorkbenchRow(raw: ContainerWorkbenchRow | ({ container: ContainerBookingResource } & Partial)): ContainerWorkbenchRow { + const source = 'container' in raw ? raw.container : raw + const drayage = raw.terminalDrayage + return { + ...container(source), + workbenchStage: raw.workbenchStage || null, + statusSummary: raw.statusSummary || {}, + allowedActions: raw.allowedActions || [], + actualFacts: raw.actualFacts || {}, + terminalDrayage: drayage ? { + ...drayage, + containerBookingId: id(drayage.containerBookingId), + version: numberValue(drayage.version), + fleetSupplierId: optionalId(drayage.fleetSupplierId), + emptyReturnWarning: { + ...drayage.emptyReturnWarning, + overdueHours: numberValue(drayage.emptyReturnWarning?.overdueHours), + overdueDays: numberValue(drayage.emptyReturnWarning?.overdueDays), + }, + allowedActions: drayage.allowedActions || [], + } : null, + } +} + function item(raw: SmartLoadingItem): SmartLoadingItem { return { ...raw, @@ -133,10 +160,11 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan { } } -export async function listContainers(query: ContainerQuery): Promise { - const response = await http.get, ApiEnvelope>(containerPath, { params: query }) +export async function listContainers(query: ContainerQuery, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise { + const params = { ...query, scope: query.scope || 'ACTIVE' } + const response = await http.get, ApiEnvelope>(`${containerPath}/${workbench.toLowerCase()}`, { params }) const result = unwrapApiResponse(response) - return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) } + return { ...result, records: (result.records || []).map((record) => normalizeWorkbenchRow(record as ContainerWorkbenchRow)), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) } } export async function getCustomerContainerTransitSummary(customerId: string): Promise { @@ -151,9 +179,9 @@ export async function getCustomerContainerTransitSummary(customerId: string): Pr } } -export async function getContainer(containerId: string) { - const response = await http.get, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}`) - return container(unwrapApiResponse(response)) +export async function getContainer(containerId: string, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise { + const response = await http.get, ApiEnvelope>(`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`) + return normalizeWorkbenchRow(unwrapApiResponse(response)) } export async function getContainerOverview(containerId: string) { @@ -161,6 +189,14 @@ export async function getContainerOverview(containerId: string) { return overview(unwrapApiResponse(response)) } +export async function updateContainerPickupCode(containerId: string, pickupCode: string, expectedVersion: number) { + const response = await http.put, ApiEnvelope>( + `${containerPath}/${encodeURIComponent(containerId)}/pickup-code`, + { pickupCode, expectedVersion }, + ) + return overview(unwrapApiResponse(response)) +} + export async function markContainerException(containerId: string, expectedVersion: number, reason: string) { const response = await http.post, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': operationId('container-exception-mark') } }) unwrapApiResponse(response) @@ -171,7 +207,7 @@ export async function cancelContainerException(containerId: string, expectedVers unwrapApiResponse(response) } -export async function uploadContainerDocument(containerId: string, documentType: Extract, file: File, expectedVersion: number, operationReason?: string | null) { +export async function uploadContainerDocument(containerId: string, documentType: Extract, file: File, expectedVersion: number, operationReason?: string | null) { const body = new FormData() body.append('file', file) body.append('expectedVersion', String(expectedVersion)) @@ -180,7 +216,7 @@ export async function uploadContainerDocument(containerId: string, documentType: return document(unwrapApiResponse(response)) } -export function downloadContainerDocument(containerId: string, documentType: Extract, fileId: string) { +export function downloadContainerDocument(containerId: string, documentType: Extract, fileId: string) { return http.get(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' }) } diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index f4137e24..97f3438f 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -8,7 +8,7 @@ import { downloadContainerPdaPhoto } from '../api/platformContainerLoading' vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() })) const overview: ContainerOverview = { - container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 }, + container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 }, loadingPlanId: '9', pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] }, documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }], @@ -47,13 +47,19 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('9007199254740999') }) - it('renders three compact non-customs document rows with the operation column first', () => { + 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()) - expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态']) + expect(headers).toEqual(['操作', '资料类型', '资料内容', '更新人', '更新时间', '状态']) expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3) expect(wrapper.findAll('.document-item')).toHaveLength(0) expect(wrapper.find('.document-table-wrap').exists()).toBe(true) + expect(wrapper.text()).not.toContain('业务资料') + const pickupInput = wrapper.find('input[aria-label="PU码"]') + expect((pickupInput.element as HTMLInputElement).value).toBe('00123456') + await pickupInput.setValue('998877') + await wrapper.find('.pickup-code-row button').trigger('click') + expect(wrapper.emitted('savePickupCode')?.[0]).toEqual(['998877']) const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))! expect(deliveryRow.text()).toContain('do.pdf') expect(deliveryRow.text()).toContain('操作员') diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 76071b84..ad57a4a9 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,5 +1,5 @@ diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index d37b6d39..ec0018c4 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -111,18 +111,26 @@ describe('ContainerFeesPanel contract', () => { expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything()) }) - it('uses the summary container ID when fee rows are empty', async () => { + it('does not request any Finance data when container fees are empty', async () => { vi.mocked(listContainerFees).mockResolvedValue([]) - await mountPanel(['firstleg:inbound:fee-detail:view']) - expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99') + await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:list']) + expect(listContainerCostDetails).not.toHaveBeenCalled() + expect(listFinanceReceivableSettlements).not.toHaveBeenCalled() + expect(listFinancePayableSettlements).not.toHaveBeenCalled() + expect(getFinanceSettlement).not.toHaveBeenCalled() }) - it('uses the summary container ID when fee rows agree', async () => { + it('loads Finance data only on an explicit single request when fee rows agree', async () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - await mountPanel(['firstleg:inbound:fee-detail:view']) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list']) + expect(listContainerCostDetails).not.toHaveBeenCalled() + expect(listFinanceReceivableSettlements).not.toHaveBeenCalled() + await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') + await flushPromises() expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99') + expect(listFinanceReceivableSettlements).toHaveBeenCalledTimes(1) }) it('fails closed when fee rows disagree with the summary container', async () => { @@ -130,7 +138,9 @@ describe('ContainerFeesPanel contract', () => { { id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:fee-item:view']) - expect(wrapper.text()).toContain('柜次费用加载失败') + await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('财务状态加载失败') expect(listContainerCostDetails).not.toHaveBeenCalled() }) @@ -161,6 +171,8 @@ describe('ContainerFeesPanel contract', () => { 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', 'platform:finance:settlement-ar:list', ]) + await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') + await flushPromises() expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false) expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false) expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1') diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index b2b9766f..c5f12a94 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -26,7 +26,10 @@ const feeItems = ref([]) const suppliers = ref([]) const costs = ref([]) const loading = ref(false) +const financeLoading = ref(false) +const financeLoaded = ref(false) const error = ref(null) +const financeError = ref(null) const feeItemsError = ref(null) const suppliersError = ref(null) const feeItemsLoading = ref(false) @@ -183,21 +186,35 @@ async function load() { const feeRows = await listContainerFees(props.planId, requestedAccountId) if (sequence !== loadSequence || requestedAccountId !== accountId.value) return fees.value = feeRows - await loadSettledFeeIds(requestedAccountId, feeRows, sequence) - if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + settledFeeIds.value = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id)) costs.value = [] - const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))] - if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊') - const currentContainerId = props.containerBookingId - if (currentContainerId) { - const result = await listContainerCostDetails(requestedAccountId, currentContainerId) - if (sequence !== loadSequence || requestedAccountId !== accountId.value) return - costs.value = result - } + financeLoaded.value = false + financeError.value = null } catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') } finally { if (sequence === loadSequence) loading.value = false } } +async function loadFinanceDetails() { + if (!accountId.value || !canView.value || !fees.value.length || financeLoading.value) return + const sequence = loadSequence + const requestedAccountId = accountId.value + const feeRows = [...fees.value] + financeLoading.value = true; financeError.value = null + try { + const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))] + if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊') + await loadSettledFeeIds(requestedAccountId, feeRows, sequence) + if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + costs.value = props.containerBookingId ? await listContainerCostDetails(requestedAccountId, props.containerBookingId) : [] + if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + financeLoaded.value = true + } catch (cause) { + if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败') + } finally { + if (sequence === loadSequence && requestedAccountId === accountId.value) financeLoading.value = false + } +} + async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) { const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id)) settledFeeIds.value = settled @@ -334,7 +351,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length = function clearCustomerContext() { loadSequence += 1 supplierLoadSequence += 1 - fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined + fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; financeError.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; financeLoading.value = false; financeLoaded.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = '' resetForm() } @@ -367,7 +384,7 @@ onMounted(() => void load()) - + @@ -376,7 +393,7 @@ onMounted(() => void load()) -

财务体积分摊

{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果

{{ cost.feeItemName || '柜次成本' }}供应商:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}
+

财务体积分摊

{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果

{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}
{{ cost.feeItemName || '柜次成本' }}供应商:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}
From 9570e0fd187372324c34552db430453de93e47b4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 21:09:25 +0800 Subject: [PATCH 03/16] perf: use container finance aggregate --- .../loading-lists/api/containerFees.test.ts | 27 ++++++------ .../loading-lists/api/containerFees.ts | 43 ++++++++++++++----- .../components/ContainerFeesPanel.test.ts | 41 ++++++------------ .../components/ContainerFeesPanel.vue | 34 +++------------ 4 files changed, 66 insertions(+), 79 deletions(-) diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index 88fdfa14..fd62af8c 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees' +import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees' import { listSuppliers } from '@/modules/suppliers' vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() })) @@ -70,20 +70,19 @@ describe('container fee API', () => { expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: undefined, page: 1, size: 200 }) }) - it('reads allocation rows from Finance and never calculates them', async () => { - const get = vi.spyOn(http, 'get') - .mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }, { id: 82, customerId: 11, containerId: 100 }] }) - .mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 11, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', payableAmount: 300 }, allocations: [{ id: 1, orderId: 8, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120 }] } }) - const result = await listContainerCostDetails('11', '99') - expect(result[0]).toMatchObject({ supplierName: '承运商A', allocations: [{ orderId: '8', allocatedAmount: 120 }] }) - expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries/81', { params: { customerId: '11' } }) - expect(get).toHaveBeenCalledTimes(2) + it('maps settled fees and allocation rows from one container aggregate request', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { + customerId: 11, containerId: 99, settledSourceFeeIds: [7], + costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], + } }) + const result = await getContainerSettlementDetails('11', '99') + expect(result).toMatchObject({ customerId: '11', containerId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) + expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerId: '99' } }) + expect(get).toHaveBeenCalledTimes(1) }) - it('fails closed when Finance detail does not echo the selected customer and container', async () => { - vi.spyOn(http, 'get') - .mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }] }) - .mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 12, containerId: 99 }, allocations: [] } }) - await expect(listContainerCostDetails('11', '99')).rejects.toThrow('Finance 成本详情与柜次或客户上下文不一致') + it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerId: 99, settledSourceFeeIds: [], costs: [] } }) + await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致') }) }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index 75556dcf..e5c648c5 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -113,13 +113,27 @@ export interface ContainerCostAllocation { export interface ContainerCostDetail { id: string + costNo: string + costType: string + costOrigin: string + sourceFeeId: string | null + sourceFeeVersion: number containerId: string | null supplierName: string | null feeItemName: string amount: number + currency: string + status: string allocations: ContainerCostAllocation[] } +export interface ContainerSettlementDetails { + customerId: string + containerId: string + settledSourceFeeIds: string[] + costs: ContainerCostDetail[] +} + const id = (value: unknown) => value == null ? '' : String(value) const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0 const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'] @@ -229,16 +243,23 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file: return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } } -export async function listContainerCostDetails(customerId: string, containerId: string): Promise { - const response = await http.get[]>, ApiEnvelope[]>>('/platform/finance/v1/cost-entries', { params: { customerId } }) - const costs = unwrapApiResponse(response).filter((raw) => id(raw.customerId) === customerId && id(raw.containerId) === containerId) - return Promise.all(costs.map(async (raw) => { - const costId = id(raw.id) - const detailResponse = await http.get; allocations: Record[] }>, ApiEnvelope<{ cost: Record; allocations: Record[] }>>(`/platform/finance/v1/cost-entries/${encodeURIComponent(costId)}`, { params: { customerId } }) - const detail = unwrapApiResponse(detailResponse) - if (id(detail.cost.id) !== costId || id(detail.cost.customerId) !== customerId || id(detail.cost.containerId) !== containerId) { - throw new Error('Finance 成本详情与柜次或客户上下文不一致') +export async function getContainerSettlementDetails(customerId: string, containerId: string): Promise { + const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerId } }) + const raw = unwrapApiResponse(response) + if (id(raw.customerId) !== customerId || id(raw.containerId) !== containerId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') + const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => { + const cost = value as Record + if (id(cost.containerId) !== containerId) throw new Error('Finance 聚合成本与柜次上下文不一致') + return { + id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''), + sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion), + containerId: cost.containerId == null ? null : id(cost.containerId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), + feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''), + allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => { + const allocation = value as Record + return { id: id(allocation.allocationId), orderId: id(allocation.orderId), inboundId: allocation.inboundId == null ? null : id(allocation.inboundId), volumeCbm: numberValue(allocation.volumeCbm), totalVolumeCbm: numberValue(allocation.totalVolumeCbm), allocationRate: numberValue(allocation.allocationRate), allocatedAmount: numberValue(allocation.allocatedAmount), roundingAdjustment: Boolean(allocation.roundingAdjustment) } + }), } - return { id: costId, containerId: detail.cost.containerId == null ? null : id(detail.cost.containerId), supplierName: detail.cost.supplierName == null ? null : String(detail.cost.supplierName), feeItemName: String(detail.cost.feeItemName || ''), amount: numberValue(detail.cost.payableAmount), allocations: (detail.allocations || []).map((item) => ({ id: id(item.id), orderId: id(item.orderId), inboundId: item.inboundId == null ? null : id(item.inboundId), volumeCbm: numberValue(item.volumeCbm), totalVolumeCbm: numberValue(item.totalVolumeCbm), allocationRate: numberValue(item.allocationRate), allocatedAmount: numberValue(item.allocatedAmount), roundingAdjustment: Boolean(item.roundingAdjustment) })) } - })) + }) + return { customerId, containerId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } } diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index ec0018c4..2023ee3b 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -6,17 +6,13 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance' -import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' +import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' import ContainerFeesPanel from './ContainerFeesPanel.vue' vi.mock('../api/containerFees', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } + return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } }) -vi.mock('@/modules/finance', () => ({ - getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), -})) const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') @@ -35,11 +31,8 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) - vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset() + vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) - vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never) - vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([]) - vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([]) vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) document.body.innerHTML = '' }) @@ -84,7 +77,7 @@ describe('ContainerFeesPanel contract', () => { }) it('shows only server allocations and supplier snapshots', () => { - expect(panel).toContain('listContainerCostDetails') + expect(panel).toContain('getContainerSettlementDetails') expect(panel).toContain('仅展示 Finance 已持久化结果') expect(panel).toContain("cost.supplierName || '服务端未返回'") expect(panel).toContain('尚无与该柜次关联的财务分摊结果') @@ -108,29 +101,25 @@ describe('ContainerFeesPanel contract', () => { resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }]) await flushPromises() expect(state.fees).toEqual([]) - expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything()) + expect(getContainerSettlementDetails).not.toHaveBeenCalledWith('11', expect.anything()) }) it('does not request any Finance data when container fees are empty', async () => { vi.mocked(listContainerFees).mockResolvedValue([]) - await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:list']) - expect(listContainerCostDetails).not.toHaveBeenCalled() - expect(listFinanceReceivableSettlements).not.toHaveBeenCalled() - expect(listFinancePayableSettlements).not.toHaveBeenCalled() - expect(getFinanceSettlement).not.toHaveBeenCalled() + await mountPanel(['firstleg:inbound:fee-detail:view']) + expect(getContainerSettlementDetails).not.toHaveBeenCalled() }) it('loads Finance data only on an explicit single request when fee rows agree', async () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list']) - expect(listContainerCostDetails).not.toHaveBeenCalled() - expect(listFinanceReceivableSettlements).not.toHaveBeenCalled() + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view']) + expect(getContainerSettlementDetails).not.toHaveBeenCalled() await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') await flushPromises() - expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99') - expect(listFinanceReceivableSettlements).toHaveBeenCalledTimes(1) + expect(getContainerSettlementDetails).toHaveBeenCalledWith('11', '99') + expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1) }) it('fails closed when fee rows disagree with the summary container', async () => { @@ -141,7 +130,7 @@ describe('ContainerFeesPanel contract', () => { await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('财务状态加载失败') - expect(listContainerCostDetails).not.toHaveBeenCalled() + expect(getContainerSettlementDetails).not.toHaveBeenCalled() }) it('blocks write entry points without a selected customer or edit permission', async () => { @@ -165,17 +154,15 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never]) - vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never) + vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: ['1'], costs: [] }) const wrapper = await mountPanel([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', - 'platform:finance:settlement-ar:list', ]) await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') await flushPromises() expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false) expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false) - expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1') + expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1) }) it('hides adjustment and void actions for directly paid payable fees', async () => { diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index c5f12a94..325a2291 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -9,8 +9,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance' -import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' +import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' const props = defineProps<{ planId: string @@ -203,10 +202,13 @@ async function loadFinanceDetails() { try { const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))] if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊') - await loadSettledFeeIds(requestedAccountId, feeRows, sequence) - if (sequence !== loadSequence || requestedAccountId !== accountId.value) return - costs.value = props.containerBookingId ? await listContainerCostDetails(requestedAccountId, props.containerBookingId) : [] + const aggregate = await getContainerSettlementDetails(requestedAccountId, props.containerBookingId) if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + settledFeeIds.value = new Set([ + ...feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id), + ...aggregate.settledSourceFeeIds, + ]) + costs.value = aggregate.costs financeLoaded.value = true } catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败') @@ -215,28 +217,6 @@ async function loadFinanceDetails() { } } -async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) { - const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id)) - settledFeeIds.value = settled - const listRequests: Array> | Awaited>>> = [] - if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId)) - if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId)) - if (!listRequests.length) return - - const settlementResults = await Promise.allSettled(listRequests) - const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled' - ? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID') - : []) - const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id))) - for (const result of detailResults) { - if (result.status !== 'fulfilled') continue - for (const line of result.value.lines) { - if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId) - } - } - if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled -} - function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() } async function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return From 653ce1c511f72902f57f62365653c0a90a04bd0f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 22:03:03 +0800 Subject: [PATCH 04/16] feat: separate container fee workbenches --- .../api/overseasContainerFees.test.ts | 25 ++++ .../api/overseasContainerFees.ts | 66 +++++++++ .../components/ContainerLastLegFeesPanel.vue | 140 ++++++++++++++++++ .../pages/ContainerDetailView.test.ts | 25 +++- .../pages/ContainerDetailView.vue | 52 +++++-- .../loading-lists/api/containerFees.test.ts | 10 +- .../loading-lists/api/containerFees.ts | 16 +- .../components/ContainerFeesPanel.test.ts | 14 +- .../components/ContainerFeesPanel.vue | 7 +- .../components/LoadingListDetailDrawer.vue | 16 -- .../components/LoadingListsView.test.ts | 2 +- 11 files changed, 325 insertions(+), 48 deletions(-) create mode 100644 src/modules/container-loading/api/overseasContainerFees.test.ts create mode 100644 src/modules/container-loading/api/overseasContainerFees.ts create mode 100644 src/modules/container-loading/components/ContainerLastLegFeesPanel.vue diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts new file mode 100644 index 00000000..c881481a --- /dev/null +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { overseasHttp } from '@/shared/api/overseasHttp' +import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees' + +const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' } +const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: '99', businessNo: 'CONT-99', orderId: null, inboundId: null, inboundCode: null, containerId: null, containerBookingId: '99', containerNo: 'CONT-99', warehouseId: null, feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', supplierId: '71', supplierName: '港口服务商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: '2026-09-08', currency: 'CNY', amount: 300 } +describe('overseas container fees API', () => { + beforeEach(() => vi.restoreAllMocks()) + it('loads all fees and customers with two container aggregate requests', async () => { + const get = vi.spyOn(overseasHttp, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) + await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }]) + await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }]) + expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99' } }) + expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } }) + }) + it('uses the exact create and lifecycle action contracts', async () => { + const post = vi.spyOn(overseasHttp, 'post').mockResolvedValue({ code: '200', data: raw }) + await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1') + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/review', { decision: 'APPROVE', reason: null, idempotencyKey: 'review-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' }) + expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' }) + }) +}) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts new file mode 100644 index 00000000..c58769bc --- /dev/null +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -0,0 +1,66 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +const basePath = '/platform/v1/last-leg/finance-fee-sources' +type RawResource = Record + +export type OverseasContainerFeeItem = 'PORT_FEE' | 'STORAGE_FEE' | 'CONTAINER_REPAIR_FEE' | 'CRANE_FEE' | 'TERMINAL_DRAYAGE_FEE' | 'OTHER' +export type OverseasContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE' + +export interface OverseasContainerCustomer { customerId: string; customerName: string | null } +export interface OverseasContainerFeeSource { + id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string | null + sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'; businessId: string; businessNo: string + containerBookingId: string; containerNo: string; feeItemCode: OverseasContainerFeeItem; feeItemName: string + direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null + settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null + paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number + reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' + reviewReason: string | null; reviewedAt: string | null +} +export interface OverseasContainerFeeSaveRequest { + customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER' + businessId: string; businessNo: string; orderId: null; inboundId: null; inboundCode: null; containerId: null + containerBookingId: string; containerNo: string; warehouseId: null; feeItemCode: OverseasContainerFeeItem; feeItemName: string + direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null + settlementMode: 'ON_ACCOUNT' | 'DIRECT_PAID' | null; paidAt: string | null + paymentMethod: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null + businessDate: string; currency: 'CNY'; amount: number +} + +const text = (value: unknown) => value == null || value === '' ? null : String(value) +const requiredText = (value: unknown) => String(value ?? '') +const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0 +function normalizeFee(raw: RawResource): OverseasContainerFeeSource { + return { + id: requiredText(raw.id), sourceFeeId: requiredText(raw.sourceFeeId), sourceVersion: numberValue(raw.sourceVersion), customerId: requiredText(raw.customerId), customerName: text(raw.customerName), + sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: requiredText(raw.businessId), businessNo: requiredText(raw.businessNo), containerBookingId: requiredText(raw.containerBookingId), containerNo: requiredText(raw.containerNo), + feeItemCode: raw.feeItemCode as OverseasContainerFeeItem, feeItemName: requiredText(raw.feeItemName), direction: raw.direction as OverseasContainerFeeDirection, + supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo), + paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount), + reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt), + } +} +export async function listOverseasContainerFees(containerBookingId: string) { + const response = await overseasHttp.get, ApiEnvelope>(basePath, { params: { containerBookingId } }) + return unwrapApiResponse(response).map(normalizeFee) +} +export async function listOverseasContainerCustomers(containerBookingId: string) { + const response = await overseasHttp.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) + return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) })) +} +export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) +} +export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) +} +export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) +} +export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) +} +export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) { + return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) +} diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue new file mode 100644 index 00000000..680ed575 --- /dev/null +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 51f3ff81..6686cbda 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -7,6 +7,7 @@ const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/containe const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8') const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8') const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8') +const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8') describe('ContainerDetailView customs integration', () => { it('renders the requested detail sections in the confirmed business order', () => { @@ -41,13 +42,35 @@ describe('ContainerDetailView customs integration', () => { expect(routeSource).toContain("name: 'overseas-container-detail'") expect(loadingRouteSource).not.toContain("name: 'loading-lists'") expect(navigationSource).not.toContain("title: '装柜清单'") - expect(source).toContain(' { + expect(source).toContain('label="头程计费" name="first-leg-fees"') + expect(source).toContain('label="尾程计费" name="last-leg-fees"') + expect(source).toContain('v-if="isDomestic && firstLegFeesOpened"') + expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"') + expect(source).toContain('title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"') + expect(source).toContain('v-if="isOverseas && lastLegFeesOpened"') + expect(source).toContain('') + expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)") + expect(source).toContain("tabName === 'loading-list' || tabName === 'first-leg-fees'") + expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"')) + expect(lastLegFeesSource).toContain('show-overflow-tooltip') + expect(lastLegFeesSource).toContain('尾程计费加载失败') + expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"') + expect(lastLegFeesSource).toContain('v-loading="loading"') + expect(lastLegFeesSource).toContain('Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)])') + expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'") + expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)') + expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')") + expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')") + }) + it('mounts the new container customs panel only after loading completes', () => { expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms") expect(source).toContain(' String(route.params.containerId || '')) const isDomestic = computed(() => props.workbench === 'DOMESTIC') const isOverseas = computed(() => props.workbench === 'OVERSEAS') const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview') +const firstLegFeesOpened = ref(false) +const lastLegFeesOpened = ref(false) +const feeCustomers = computed(() => { + const customers = new Map() + for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName) + return [...customers].map(([accountId, customerName]) => ({ accountId, customerName })) +}) +const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({ + accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo, + inboundId: item.inboundId, inboundCode: item.inboundCode, +}))) +async function rememberWorkbenchTab(tabName: string | number) { + if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true + if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true + if (isDomestic.value && (tabName === 'loading-list' || tabName === 'first-leg-fees') && detail.value && overview.value) { + await loadLoadingDetail(detail.value, overview.value) + } +} const workbenchDetail = ref(null) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) @@ -90,6 +109,7 @@ const outboundError = ref(null) const showPassedGates = ref(false) const carrierRefreshKey = ref(0) const loadingDetail = ref(null) +const loadingDetailLoading = ref(false) const loadingDetailError = ref(null) const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null) @@ -173,7 +193,7 @@ async function load() { const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)]) workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview' - if (isDomestic.value) await loadLoadingDetail(resource, aggregate) + if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate) if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch() } catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') } finally { loading.value = false } @@ -188,8 +208,10 @@ async function loadOutboundDispatch() { } async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) { + if (loadingDetail.value || loadingDetailLoading.value) return loadingDetail.value = null; loadingDetailError.value = null if (!isDomestic.value || !aggregate.loadingPlanId || !resource.warehouseId) return + loadingDetailLoading.value = true try { if (resource.planStatus === 'COMPLETED') { if (!canViewLoadingList.value) return @@ -200,6 +222,8 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId) } catch (cause) { loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败') + } finally { + loadingDetailLoading.value = false } } async function refreshAfterCustoms() { @@ -383,21 +407,21 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index fd62af8c..586f080f 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -72,17 +72,17 @@ describe('container fee API', () => { it('maps settled fees and allocation rows from one container aggregate request', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { - customerId: 11, containerId: 99, settledSourceFeeIds: [7], - costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], + customerId: 11, containerBookingId: 99, settledSourceFeeIds: [7], + costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerBookingId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], } }) const result = await getContainerSettlementDetails('11', '99') - expect(result).toMatchObject({ customerId: '11', containerId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) - expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerId: '99' } }) + expect(result).toMatchObject({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', containerBookingId: '99', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) + expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerBookingId: '99' } }) expect(get).toHaveBeenCalledTimes(1) }) it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => { - vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerId: 99, settledSourceFeeIds: [], costs: [] } }) + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerBookingId: 99, settledSourceFeeIds: [], costs: [] } }) await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致') }) }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index e5c648c5..5d16c89d 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -118,7 +118,7 @@ export interface ContainerCostDetail { costOrigin: string sourceFeeId: string | null sourceFeeVersion: number - containerId: string | null + containerBookingId: string | null supplierName: string | null feeItemName: string amount: number @@ -129,7 +129,7 @@ export interface ContainerCostDetail { export interface ContainerSettlementDetails { customerId: string - containerId: string + containerBookingId: string settledSourceFeeIds: string[] costs: ContainerCostDetail[] } @@ -243,17 +243,17 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file: return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } } -export async function getContainerSettlementDetails(customerId: string, containerId: string): Promise { - const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerId } }) +export async function getContainerSettlementDetails(customerId: string, containerBookingId: string): Promise { + const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerBookingId } }) const raw = unwrapApiResponse(response) - if (id(raw.customerId) !== customerId || id(raw.containerId) !== containerId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') + if (id(raw.customerId) !== customerId || id(raw.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => { const cost = value as Record - if (id(cost.containerId) !== containerId) throw new Error('Finance 聚合成本与柜次上下文不一致') + if (id(cost.containerBookingId) !== containerBookingId) throw new Error('Finance 聚合成本与柜次上下文不一致') return { id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''), sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion), - containerId: cost.containerId == null ? null : id(cost.containerId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), + containerBookingId: cost.containerBookingId == null ? null : id(cost.containerBookingId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''), allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => { const allocation = value as Record @@ -261,5 +261,5 @@ export async function getContainerSettlementDetails(customerId: string, containe }), } }) - return { customerId, containerId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } + return { customerId, containerBookingId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } } diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index 2023ee3b..000e4e73 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -16,6 +16,7 @@ vi.mock('../api/containerFees', async (importOriginal) => { const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') +const containerDetail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8') class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} } function memoryStorage(): Storage { const values = new Map(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } } @@ -31,7 +32,7 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) - vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() + vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) document.body.innerHTML = '' @@ -45,9 +46,12 @@ describe('ContainerFeesPanel contract', () => { expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;') }) - it('is exposed from loading-list detail and derives customer context from real items', () => { - expect(detail).toContain(':container-booking-id="summary.containerBookingId"') - expect(detail).toContain('item.accountId') + it('is exposed from the domestic container fee tab and derives customer context from real items', () => { + expect(detail).not.toContain('ContainerFeesPanel') + expect(containerDetail).toContain('title="头程计费"') + expect(containerDetail).toContain(':container-booking-id="detail.id"') + expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"') + expect(containerDetail).toContain('item.accountId') expect(panel).toContain('该柜次包含多个客户,请先选择费用归属客户') expect(panel).toContain('装柜明细缺少客户归属,无法安全维护费用') }) @@ -154,7 +158,7 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: ['1'], costs: [] }) + vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: ['1'], costs: [] }) const wrapper = await mountPanel([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', ]) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 325a2291..3755597c 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -11,12 +11,13 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' -const props = defineProps<{ +const props = withDefaults(defineProps<{ + title?: string planId: string containerBookingId: string customers: Array<{ accountId: string; customerName: string | null }> businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }> -}>() +}>(), { title: '柜次费用' }) const access = useAccessPreviewStore() const accountId = ref('') const fees = ref([]) @@ -350,7 +351,7 @@ onMounted(() => void load())