feat: split domestic and overseas container workbenches

This commit is contained in:
2026-09-08 19:57:42 +08:00
parent 296a8246ad
commit 8910e88563
26 changed files with 365 additions and 219 deletions
+6 -5
View File
@@ -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/` |
+1 -1
View File
@@ -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')
})
+9 -7
View File
@@ -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',
])
})
+2 -2
View File
@@ -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' },
],
},
{
@@ -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: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', 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'
@@ -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>)): 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<ContainerPage> {
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(containerPath, { params: query })
export async function listContainers(query: ContainerQuery, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerPage> {
const params = { ...query, scope: query.scope || 'ACTIVE' }
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(`${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<CustomerContainerTransitSummary> {
@@ -151,9 +179,9 @@ export async function getCustomerContainerTransitSummary(customerId: string): Pr
}
}
export async function getContainer(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
return container(unwrapApiResponse(response))
export async function getContainer(containerId: string, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerWorkbenchRow> {
const response = await http.get<ApiEnvelope<ContainerWorkbenchRow>, ApiEnvelope<ContainerWorkbenchRow>>(`${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<ContainerOverview>, ApiEnvelope<ContainerOverview>>(
`${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<null>, ApiEnvelope<null>>(`${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<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, file: File, expectedVersion: number, operationReason?: string | null) {
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER'>, 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<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, fileId: string) {
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER'>, fileId: string) {
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
@@ -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('操作员')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -12,23 +12,27 @@ const props = defineProps<{
canManageCustoms: boolean
canManageException: boolean
documentBusy?: boolean
pickupCodeBusy?: boolean
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
savePickupCode: [pickupCode: string]
manageException: []
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const containerDownloadableTypes = new Set(['DELIVERY_ORDER'])
const pickupCodeInput = ref('')
watch(() => props.overview.container.pickupCode, (value) => { pickupCodeInput.value = value || '' }, { immediate: true })
const pickupCodeValid = computed(() => /^[0-9]{1,64}$/.test(pickupCodeInput.value))
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
@@ -119,11 +123,10 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
</section>
<section v-if="visibleSections.has('documents')" class="overview-section" aria-label="货柜业务资料">
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap">
<table class="document-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
@@ -131,6 +134,13 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
</tr><tr class="pickup-code-row">
<td class="document-table__actions"><el-button v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
<td><strong>PU 资料</strong></td>
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody>
</table>
</div>
@@ -149,12 +159,13 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<style scoped>
.overview-panel { display: grid; gap: 12px; }
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-section--documents{padding:0;border:0}
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.overview-section h3 { margin: 0; font-size: 15px; }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
@@ -10,9 +10,8 @@ describe('ContainerDetailView customs integration', () => {
const headings = [
'container-information-heading">货柜信息',
'container-voyage-heading">船名航次信息',
'container-customs-heading">报关清关信息',
'container-receiving-heading">签收信息',
'container-fees-heading">费用明细',
'container-customs-heading">{{ isDomestic',
'container-receiving-heading">装柜作业与国内出仓',
]
let previous = -1
for (const heading of headings) {
@@ -26,9 +25,12 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain('<CarrierScheduleQueryPanel')
expect(source).toContain(':sections="[\'documents\']"')
expect(source).toContain(':sections="[\'pda\']"')
expect(source).toContain('<ContainerFeesPanel')
expect(source).toContain('<LoadingListDetailDrawer')
expect(source).toContain('getConfirmedLoadingList')
expect(source).toContain('getPreloadPlan')
expect(source).toContain('label="装柜清单"')
expect(source).toContain('打印装柜清单')
expect(source).toContain(':sections="[\'pda\']"')
})
it('mounts the new container customs panel only after loading completes', () => {
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, type LoadingDetail } from '@/modules/loading-lists'
import { getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
@@ -31,21 +31,29 @@ import {
markContainerException,
uploadContainerClearance,
uploadContainerDocument,
updateContainerPickupCode,
} from '../api/platformContainerLoading'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
const workbenchDetail = ref<ContainerWorkbenchRow | null>(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'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS'))
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewContainerFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
&& detail.value?.sourceType === 'SIMULATION'
&& !detail.value.oceanBookingId
@@ -56,7 +64,7 @@ const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
type UploadDocumentType = 'DELIVERY_ORDER'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentUploadKey = ref(0)
@@ -64,6 +72,7 @@ const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
@@ -84,7 +93,7 @@ const loadingDetail = ref<LoadingDetail | null>(null)
const loadingDetailError = ref<ApiErrorDetails | null>(null)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
const documentTitle = computed(() => 'DO 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
@@ -110,27 +119,14 @@ const outboundGateSummary = computed(() => ({
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
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,
})))
function openOceanBookingLink() {
if (!detail.value || !canLinkOceanBooking.value) return
void router.push({ name: 'container-loading', query: { linkContainerId: detail.value.id } })
void router.push({ name: 'domestic-container-workbench', query: { linkContainerId: detail.value.id } })
}
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
@@ -174,10 +170,11 @@ async function load() {
if (!containerId.value) return
loading.value = true; error.value = null; overviewError.value = null
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
await loadLoadingDetail(resource, aggregate)
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
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 && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
@@ -192,7 +189,7 @@ async function loadOutboundDispatch() {
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
loadingDetail.value = null; loadingDetailError.value = null
if (!canViewContainerFees.value || !aggregate.loadingPlanId || !resource.warehouseId) return
if (!isDomestic.value || !aggregate.loadingPlanId || !resource.warehouseId) return
try {
if (resource.planStatus === 'COMPLETED') {
if (!canViewLoadingList.value) return
@@ -202,7 +199,7 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate:
if (!canViewPreload.value) return
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
} catch (cause) {
loadingDetailError.value = getApiErrorDetails(cause, '费用归属信息加载失败')
loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败')
}
}
async function refreshAfterCustoms() {
@@ -242,8 +239,8 @@ function truckReferenceText(status?: string | null) {
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
@@ -280,12 +277,12 @@ async function saveDocument() {
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
if (!['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
documentDownloading.value = true
try {
const blob = item.documentType === 'DESTINATION_CLEARANCE'
? await downloadContainerClearance(containerId.value, item.fileId)
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
: await downloadContainerDocument(containerId.value, 'DELIVERY_ORDER', item.fileId)
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
} catch (cause) {
const details = getApiErrorDetails(cause, '资料下载失败')
@@ -294,6 +291,20 @@ async function downloadDocument(item: ContainerOverviewDocument) {
}
finally { documentDownloading.value = false }
}
async function savePickupCode(pickupCode: string) {
if (!overview.value || !canManageDestinationDocuments.value || !/^[0-9]{1,64}$/.test(pickupCode)) return
pickupCodeSaving.value = true; documentRefreshWarning.value = null
try {
const refreshed = await updateContainerPickupCode(containerId.value, pickupCode, overview.value.container.version)
overview.value = refreshed
if (detail.value) detail.value = { ...detail.value, pickupCode: refreshed.container.pickupCode, pickupCodeUpdatedBy: refreshed.container.pickupCodeUpdatedBy, pickupCodeUpdatedByName: refreshed.container.pickupCodeUpdatedByName, pickupCodeUpdatedAt: refreshed.container.pickupCodeUpdatedAt, version: refreshed.container.version }
ElMessage.success('PU 码已保存')
} catch (cause) {
const details = getApiErrorDetails(cause, 'PU 码保存失败')
documentRefreshWarning.value = details
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留并发错误证据 */ }
} finally { pickupCodeSaving.value = false }
}
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
async function saveException() {
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
@@ -358,40 +369,43 @@ async function completeClearance() {
finally { clearanceSaving.value = false }
}
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
function printLoadingList() { window.print() }
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
</script>
<template>
<AdminPage class="container-detail-page">
<section class="container-overview surface-panel">
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
<header class="container-overview__header"><el-button link type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>{{ isDomestic ? '送柜、报关、装柜清单与国内出仓' : '海运轨迹、目的港清关、提柜与还空' }}</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<el-tabs v-if="isDomestic" v-model="activeTab" class="workbench-tabs"><el-tab-pane label="送柜报关" name="overview" /><el-tab-pane v-if="detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /></el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section class="detail-section surface-panel" aria-labelledby="container-information-heading">
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<h2 id="container-information-heading">货柜信息</h2>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<div class="detail-fields"><dl><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">报关清关信息</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['documents']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" />
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
</section>
<section class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">签收信息</h2>
<div class="detail-fields"><dl><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></div>
<section v-if="isDomestic && activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
@@ -411,12 +425,16 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
</div>
</section>
<section class="detail-section surface-panel" aria-labelledby="container-fees-heading">
<h2 id="container-fees-heading">费用明细</h2>
<LoadErrorState v-if="loadingDetailError" compact title="费用明细加载失败" :message="loadingDetailError.message" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-alert v-else-if="!canViewContainerFees" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
<el-empty v-else description="当前货柜尚无可读取的装柜费用上下文" :image-size="64" />
<section v-if="isOverseas" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2>
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据、费用与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer mode="confirmed" :loading="loading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
</template>
@@ -11,7 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getTerminalDrayage, getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages'
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages'
import AppDialog from '@/shared/components/AppDialog.vue'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
@@ -35,9 +35,10 @@ const drayage = {
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: ContainersView },
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/loading-lists', name: 'loading-lists', component: { template: '<div />' } },
{ path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '<div />' } },
{ path: '/container-loading/overseas/:containerId', name: 'overseas-container-detail', component: { template: '<div />' } },
] })
}
@@ -51,13 +52,12 @@ describe('ContainersView', () => {
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
vi.mocked(listTerminalDrayages).mockResolvedValue([{ ...drayage, allowedActions: [...drayage.allowedActions] }])
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] })
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -66,15 +66,13 @@ describe('ContainersView', () => {
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, scope: 'ACTIVE' }), 'DOMESTIC')
expect(wrapper.text()).toContain('待装柜')
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(listTerminalDrayages).toHaveBeenCalledWith(['9001'])
expect(wrapper.text()).toContain('超时 1 天')
expect(wrapper.text()).toContain('2026-09-10 10:00')
expect(wrapper.text()).not.toContain('报空预警')
})
it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
@@ -150,7 +148,7 @@ describe('ContainersView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'domestic-container-detail', params: { containerId: '9003' }, query: { tab: 'loading-list' } })
})
it('uses active customer names and sends the stable customerId filter', () => {
@@ -209,8 +207,7 @@ describe('ContainersView', () => {
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("router.push({ name: 'container-detail', params: { containerId: row.id } })")
expect(source).toContain("query: { action: command }")
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('max-height: 90vh')
@@ -223,10 +220,11 @@ describe('ContainersView', () => {
it('registers a list-authorized independent container detail page', () => {
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(routes).toContain("path: 'container-loading/:containerId'")
expect(routes).toContain("name: 'container-detail'")
expect(routes).toContain("path: 'container-loading/domestic/:containerId'")
expect(routes).toContain("name: 'domestic-container-detail'")
expect(routes).toContain("name: 'overseas-container-detail'")
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value)')
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
expect(detail).toContain('getContainerOverview(containerId.value)')
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
@@ -249,7 +247,7 @@ describe('ContainersView', () => {
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')")
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
@@ -265,7 +263,7 @@ describe('ContainersView', () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
@@ -304,9 +302,9 @@ describe('ContainersView', () => {
it('opens the existing released-booking workflow when entered from container detail', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('route.query.linkContainerId')
expect(source).toContain('const target = await getContainer(linkContainerId)')
expect(source).toContain("const target = await getContainer(linkContainerId, 'DOMESTIC')")
expect(source).toContain('await openLink(target)')
expect(source).toContain("void router.replace({ name: 'container-loading' })")
expect(source).toContain("void router.replace({ name: 'domestic-container-workbench' })")
})
it('normalizes an optional booking-link container number', async () => {
@@ -399,7 +397,7 @@ describe('ContainersView', () => {
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
@@ -426,7 +424,7 @@ describe('ContainersView', () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
@@ -491,7 +489,7 @@ describe('ContainersView', () => {
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -511,7 +509,7 @@ describe('ContainersView', () => {
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -533,7 +531,7 @@ describe('ContainersView', () => {
})
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
@@ -555,7 +553,7 @@ describe('ContainersView', () => {
describe('ContainersView trucking supplier contract', () => {
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`key: '${field}'`, previous + 1)
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -22,7 +22,6 @@ import { createContainerSimulation, getContainer, linkContainerOceanBooking, lis
import {
getTerminalDrayage,
getTerminalDrayageHistory,
listTerminalDrayages,
recordTerminalDrayageEmptyReturn,
recordTerminalDrayagePickup,
scheduleTerminalDrayagePickup,
@@ -31,15 +30,17 @@ import {
type TerminalDrayageHistory,
type TerminalDrayageView,
} from '../api/terminalDrayages'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const router = useRouter()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const rows = ref<ContainerWorkbenchRow[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
@@ -56,6 +57,7 @@ const businessOptionsError = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detail = ref<ContainerBookingResource | null>(null)
@@ -81,8 +83,6 @@ const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirem
const selectedTruckingSupplierId = ref('')
const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
const drayageLoading = ref(false)
const drayageLoadError = ref<ApiErrorDetails | null>(null)
const drayageSavingIds = ref<string[]>([])
const drayageActionOpen = ref(false)
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
@@ -98,7 +98,6 @@ const drayageHistoryError = ref<ApiErrorDetails | null>(null)
let listSequence = 0
let detailSequence = 0
let linkSequence = 0
let drayageSequence = 0
function emptyForm(): ContainerForm {
return {
@@ -118,11 +117,17 @@ const rules: FormRules<ContainerForm> = {
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
}
const canView = computed(() => access.can('platform:firstleg:container:list'))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const canView = computed(() => isDomestic.value ? access.can('platform:firstleg:container:list') : access.can('platform:firstleg:terminal-drayage:list'))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit'))
const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit'))
const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit'))
const canPickupConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:pickup:confirm'))
const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm'))
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
@@ -166,7 +171,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
const allColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
@@ -176,7 +181,7 @@ const columns: DataTableColumn[] = [
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
@@ -196,6 +201,9 @@ const columns: DataTableColumn[] = [
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const columns = computed(() => allColumns.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))))
const drayageHistoryColumns: DataTableColumn[] = [
{ key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) },
{ key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` },
@@ -213,7 +221,11 @@ function formatTime(value: string | null | undefined) { return value?.replace('T
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record<TerminalDrayageAction, string>)[action] }
function canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) }
function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) }
function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) {
const permission = action === 'SELECT_FLEET' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value
return Boolean(isOverseas.value && permission && hasAction(row, action) && drayageFor(row.id)?.allowedActions.includes(action))
}
function drayageWarningText(row: ContainerBookingResource) {
const warning = drayageFor(row.id)?.emptyReturnWarning
if (!warning || warning.status === 'NOT_APPLICABLE') return '—'
@@ -477,20 +489,6 @@ async function saveLink() {
function replaceDrayage(value: TerminalDrayageView) {
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
}
async function loadDrayages(targetRows = rows.value) {
const sequence = ++drayageSequence
if (!targetRows.length) { drayages.value = {}; drayageLoadError.value = null; return }
drayageLoading.value = true; drayageLoadError.value = null
try {
const values = await listTerminalDrayages(targetRows.map((row) => row.id))
if (sequence !== drayageSequence) return
drayages.value = Object.fromEntries(values.map((value) => [value.containerBookingId, value]))
} catch (error) {
if (sequence !== drayageSequence) return
drayages.value = {}
drayageLoadError.value = getApiErrorDetails(error, '码头拖车信息加载失败,请重试')
} finally { if (sequence === drayageSequence) drayageLoading.value = false }
}
async function refreshDrayage(containerBookingId: string) {
const value = await getTerminalDrayage(containerBookingId)
replaceDrayage(value)
@@ -558,11 +556,11 @@ async function loadRows() {
page: page.value, size: pageSize.value, customerId: applied.value.customerId || undefined, keyword: applied.value.keyword.trim() || undefined,
containerType: applied.value.containerType || undefined,
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
})
estimatedSailingTo: to ? `${to}T23:59:59` : undefined, scope: scope.value,
}, props.workbench)
if (sequence !== listSequence) return
rows.value = result.records; total.value = result.total
await loadDrayages(result.records)
rows.value = result.records as ContainerWorkbenchRow[]; total.value = result.total
drayages.value = Object.fromEntries(rows.value.flatMap((row) => row.terminalDrayage ? [[row.id, row.terminalDrayage as TerminalDrayageView]] : []))
} catch (error) {
if (sequence !== listSequence) return
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
@@ -571,7 +569,7 @@ async function loadRows() {
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
function openDetail(source: object) {
const row = source as ContainerBookingResource
void router.push({ name: 'container-detail', params: { containerId: row.id } })
void router.push({ name: props.workbench === 'DOMESTIC' ? 'domestic-container-detail' : 'overseas-container-detail', params: { containerId: row.id } })
}
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
@@ -579,17 +577,14 @@ function handleMoreAction(command: string, source: object) {
if (command === 'overview') { openDetail(row); return }
if (command === 'drayage-history') { void openDrayageHistory(row); return }
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET'>); return }
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try {
const latest = await getContainer(row.id)
if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) {
ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单')
return
}
const latest = await getContainer(row.id, props.workbench)
if (!isDomestic.value || !hasAction(latest, 'EDIT_CONTAINER')) return
detail.value = latest
replaceForm(latest)
editorOpen.value = true
@@ -612,7 +607,7 @@ async function save() {
if (details.code === 'A0409' && form.id) {
const submitted = { ...form }
try {
const latest = await getContainer(form.id)
const latest = await getContainer(form.id, props.workbench)
detail.value = latest
replaceForm(latest)
Object.assign(form, submitted, { id: latest.id, version: latest.version })
@@ -635,14 +630,16 @@ function goContainerProcess(source: object) {
return
}
void router.push({
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
params: row.planStatus === 'COMPLETED' ? { containerId: row.id } : undefined,
query: row.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: row.planId || undefined, warehouseId: row.warehouseId },
})
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
watch(linkOpen, (open) => {
if (open) return
linkSequence += 1
@@ -651,10 +648,10 @@ watch(linkOpen, (open) => {
onMounted(async () => {
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
if (!linkContainerId || !canEdit.value) return
if (!isDomestic.value || !linkContainerId || !canEdit.value) return
try {
const target = await getContainer(linkContainerId)
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !['WAIT_LOADING', 'EDITING'].includes(target.planStatus)) {
const target = await getContainer(linkContainerId, 'DOMESTIC')
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !hasAction(target, 'EDIT_CONTAINER')) {
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
return
}
@@ -662,7 +659,7 @@ onMounted(async () => {
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
} finally {
void router.replace({ name: 'container-loading' })
void router.replace({ name: 'domestic-container-workbench' })
}
})
</script>
@@ -670,7 +667,7 @@ onMounted(async () => {
<template>
<AdminPage as="div">
<section class="container-page">
<ListPageLayout ariaLabel="货柜列表">
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</template>
@@ -678,10 +675,9 @@ onMounted(async () => {
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<el-alert v-if="drayageLoadError" class="drayage-load-alert" type="warning" :closable="false" show-icon :title="drayageLoadError.message"><el-button link type="warning" :loading="drayageLoading" @click="loadDrayages()">重新加载码头拖车信息</el-button></el-alert>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #toolbar-actions><el-radio-group v-model="scope" size="small" aria-label="工作台范围"><el-radio-button value="ACTIVE">当前履约</el-radio-button><el-radio-button value="HISTORY">历史货柜</el-radio-button></el-radio-group><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
@@ -697,9 +693,9 @@ onMounted(async () => {
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
</DataTable>
</section>
</ListPageLayout>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerDetailView from './ContainerDetailView.vue'
</script>
<template><ContainerDetailView workbench="DOMESTIC" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainersView from './ContainersView.vue'
</script>
<template><ContainersView workbench="DOMESTIC" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerDetailView from './ContainerDetailView.vue'
</script>
<template><ContainerDetailView workbench="OVERSEAS" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainersView from './ContainersView.vue'
</script>
<template><ContainersView workbench="OVERSEAS" /></template>
@@ -216,8 +216,9 @@ async function load() {
function openOperationalList() {
if (!container.value || container.value.planStatus === 'EDITING') return
void router.push({
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
params: container.value.planStatus === 'COMPLETED' ? { containerId: container.value.id } : undefined,
query: container.value.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
})
}
function openAssign(source: object) {
+21 -9
View File
@@ -2,21 +2,33 @@ import type { RouteRecordRaw } from 'vue-router'
export const containerLoadingRoutes = [
{
path: 'container-loading',
name: 'container-loading',
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜管理', description: '维护货柜资料并进入智能装柜工作台', permission: 'platform:firstleg:container:list' },
path: 'container-loading/domestic',
name: 'domestic-container-workbench',
component: () => import('./pages/DomesticContainersView.vue'),
meta: { title: '送柜报关', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
},
{
path: 'container-loading/:containerId',
name: 'container-detail',
component: () => import('./pages/ContainerDetailView.vue'),
meta: { title: '货柜详情', description: '查看货柜资料、履约轨迹、单证与异常', permission: 'platform:firstleg:container:list', showPageHeading: false },
path: 'container-loading/domestic/:containerId',
name: 'domestic-container-detail',
component: () => import('./pages/DomesticContainerDetailView.vue'),
meta: { title: '送柜报关详情', description: '查看送柜、装柜清单、报关与出仓', permission: 'platform:firstleg:container:list', showPageHeading: false },
},
{
path: 'container-loading/:containerId/smart-loading',
path: 'container-loading/domestic/:containerId/smart-loading',
name: 'smart-loading-workbench',
component: () => import('./pages/SmartLoadingWorkbenchView.vue'),
meta: { title: '智能装柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
},
{
path: 'container-loading/overseas',
name: 'overseas-container-workbench',
component: () => import('./pages/OverseasContainersView.vue'),
meta: { title: '清关提柜', description: '管理海运轨迹、目的港清关、提柜与还空', permission: 'platform:firstleg:terminal-drayage:list' },
},
{
path: 'container-loading/overseas/:containerId',
name: 'overseas-container-detail',
component: () => import('./pages/OverseasContainerDetailView.vue'),
meta: { title: '清关提柜详情', description: '查看清关资料、码头拖车与还空轨迹', permission: 'platform:firstleg:terminal-drayage:list', showPageHeading: false },
},
] satisfies RouteRecordRaw[]
+58 -4
View File
@@ -46,12 +46,15 @@ export interface ContainerBookingResource {
estimatedEmptyReportedAt?: string | null
actualEmptyReportedAt?: string | null
emptyReportSourceVersion?: string | null
pickupCode?: string | null
pickupCodeUpdatedBy?: string | null
pickupCodeUpdatedByName?: string | null
pickupCodeUpdatedAt?: string | null
planStatus: ContainerPlanStatus
exceptionActive?: boolean
destinationClearanceDocumentStatus?: ContainerDocumentStatus
customsDeclarationDocumentStatus?: ContainerDocumentStatus
deliveryOrderDocumentStatus?: ContainerDocumentStatus
pickupOrderDocumentStatus?: ContainerDocumentStatus
version: number
createdAt?: string | null
updatedAt?: string | null
@@ -88,7 +91,7 @@ export interface BusinessDocument {
version: number
}
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER'
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
export interface ContainerOverviewDocument {
@@ -169,7 +172,7 @@ export interface ContainerTimelineEntry {
}
export interface ContainerOverview {
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'version'> & { status: string }
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
loadingPlanId: string | null
pda: ContainerPdaSummary | null
documents: ContainerOverviewDocument[]
@@ -179,7 +182,7 @@ export interface ContainerOverview {
}
export interface ContainerPage {
records: ContainerBookingResource[]
records: ContainerWorkbenchRow[]
page: number
size: number
total: number
@@ -198,6 +201,57 @@ export interface ContainerQuery {
status?: ContainerPlanStatus | ''
estimatedSailingFrom?: string
estimatedSailingTo?: string
scope?: ContainerWorkbenchScope
}
export type ContainerWorkbenchKind = 'DOMESTIC' | 'OVERSEAS'
export type ContainerWorkbenchScope = 'ACTIVE' | 'HISTORY'
export type ContainerWorkbenchAction = 'EDIT_CONTAINER' | 'MANAGE_CUSTOMS' | 'CONFIRM_OUTBOUND' | 'MANAGE_DESTINATION_DOCUMENTS' | 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN'
export interface ContainerWorkbenchStatusSummary {
loadingStatus?: string | null
customsStatus?: string | null
outboundStatus?: string | null
destinationClearanceStatus?: string | null
deliveryOrderStatus?: string | null
pickupCodeStatus?: string | null
latestActualNode?: string | null
latestActualAt?: string | null
terminalDrayageStatus?: string | null
emptyReturnWarningStatus?: string | null
}
export interface ContainerWorkbenchActualFacts {
actualSailingAt?: string | null
actualPortArrivalAt?: string | null
actualWarehouseArrivalAt?: string | null
unloadingCompletedAt?: string | null
actualEmptyReturnAt?: string | null
[key: string]: unknown
}
export interface ContainerWorkbenchTerminalDrayage {
containerBookingId: string
status: string
version: number
fleetSupplierId: string | null
fleetSupplierName: string | null
pickupAppointmentAt: string | null
actualPickupAt: string | null
estimatedEmptyReturnAt: string | null
actualEmptyReturnAt: string | null
unloadingCompletedAt: string | null
emptyReturnWarning: { status: 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED'; deadlineAt: string | null; overdueHours: number; overdueDays: number }
allowedActions: ReadonlyArray<'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN'>
updatedAt: string | null
}
export interface ContainerWorkbenchRow extends ContainerBookingResource {
workbenchStage?: string | null
statusSummary?: ContainerWorkbenchStatusSummary
allowedActions?: ContainerWorkbenchAction[]
actualFacts?: ContainerWorkbenchActualFacts
terminalDrayage?: ContainerWorkbenchTerminalDrayage | null
}
export interface ContainerSaveRequest {
@@ -57,7 +57,7 @@ describe('LoadingListsView', () => {
expect(source).not.toContain('internalContainerNo')
})
it('opens both preload and confirmed details as independent route pages', () => {
it('keeps preload routes while completed lists move into domestic container detail', () => {
expect(source).toContain("name: 'preload-plan-detail'")
expect(source).toContain("name: 'loading-list-detail'")
expect(source).not.toContain('confirmed-inline-detail')
@@ -66,8 +66,8 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('返回列表')
expect(confirmedDetailSource).toContain('router.back()')
expect(confirmedDetailSource).toContain('@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }')
expect(routesSource).toContain("path: 'loading-lists/:planId'")
expect(routesSource).toContain("name: 'loading-list-detail'")
expect(routesSource).not.toContain("path: 'loading-lists/:planId'")
expect(routesSource).not.toContain("name: 'loading-list-detail'")
})
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
+1
View File
@@ -2,3 +2,4 @@ export * from './api/platformLoadingLists'
export { loadingListRoutes } from './routes'
export { loadingListStatusLabel } from './status'
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
@@ -2,7 +2,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessage } from 'element-plus'
import { ApiResponseError, http } from '@/shared/api/http'
import { getPreloadPlan } from '../api/platformLoadingLists'
import { getConfirmedLoadingList, getPreloadPlan } from '../api/platformLoadingLists'
import PreloadPlanDetailView from './PreloadPlanDetailView.vue'
const routerReplace = vi.hoisted(() => vi.fn())
@@ -15,6 +15,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: removeWorkspaceTabs }) }))
vi.mock('../api/platformLoadingLists', () => ({
confirmPreloadPlan: vi.fn(),
getConfirmedLoadingList: vi.fn(),
getPreloadPlan: vi.fn(),
updateLoadingItemWarehouseRemark: vi.fn(),
updatePreloadTransport: vi.fn(),
@@ -52,6 +53,7 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
it('replaces the current tab after authoritative lifecycle resolution', async () => {
vi.spyOn(http, 'get').mockResolvedValue(lifecycle('LOADING_LIST', 'COMPLETED'))
vi.mocked(getConfirmedLoadingList).mockResolvedValue({ summary: { containerBookingId: '2900000000000000001' }, items: [], histories: [] } as never)
const message = vi.spyOn(ElMessage, 'success').mockImplementation(() => undefined as never)
const wrapper = await mountView()
@@ -63,9 +65,9 @@ describe('PreloadPlanDetailView lifecycle handoff', () => {
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(routerReplace).toHaveBeenCalledTimes(1)
expect(routerReplace).toHaveBeenCalledWith({
name: 'loading-list-detail',
params: { planId: '1900000000000000001' },
query: { warehouseId: '1900000000000000002' },
name: 'domestic-container-detail',
params: { containerId: '2900000000000000001' },
query: { tab: 'loading-list' },
})
expect(removeWorkspaceTabs).toHaveBeenCalledWith(['preload-plan-detail'])
expect(routerReplace.mock.invocationCallOrder[0]).toBeLessThan(removeWorkspaceTabs.mock.invocationCallOrder[0]!)
@@ -24,11 +24,11 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain('/loading-plans/${encodeURIComponent(planId.value)}/lifecycle')
expect(source).toContain("lifecycle.detailResource === 'LOADING_LIST'")
expect(source).toContain("lifecycle.planStatus !== 'COMPLETED'")
expect(source).toContain("router.replace({ name: 'loading-list-detail'")
expect(source).toContain("router.replace({ name: 'domestic-container-detail'")
expect(source).toContain("workspace.removeByIds(['preload-plan-detail'])")
expect(source).toContain('装柜已完成,已切换至装柜清单详情')
expect(source).not.toContain("preloadError.code !== 'A0404'")
expect(source).not.toContain('getConfirmedLoadingList')
expect(source).toContain('getConfirmedLoadingList')
expect(source).not.toContain("router.push({ name: 'loading-list-detail'")
})
@@ -11,7 +11,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -96,7 +96,8 @@ async function loadDetail() {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '装柜计划阶段异常,暂时无法切换详情,请稍后重试。' }
return
}
await router.replace({ name: 'loading-list-detail', params: { planId: planId.value }, query: { warehouseId: warehouseId.value } })
const confirmed = await getConfirmedLoadingList(planId.value, warehouseId.value)
await router.replace({ name: 'domestic-container-detail', params: { containerId: confirmed.summary.containerBookingId }, query: { tab: 'loading-list' } })
workspace.removeByIds(['preload-plan-detail'])
if (sequence === loadSequence) ElMessage.success('装柜已完成,已切换至装柜清单详情')
return
+1 -3
View File
@@ -2,12 +2,10 @@ import { describe, expect, it } from 'vitest'
import { loadingListRoutes } from './routes'
describe('loading list routes', () => {
it('declares preload list/detail and completed list entries with backend permissions', () => {
it('keeps completed loading lists inside domestic container detail', () => {
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
])
})
})
-20
View File
@@ -21,24 +21,4 @@ export const loadingListRoutes = [
permission: 'platform:firstleg:preload:list',
},
},
{
path: 'loading-lists',
name: 'loading-lists',
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
meta: {
title: '装柜清单',
description: '查看全部仓库已完成且永久只读的装柜清单',
permission: 'platform:firstleg:loading-list:list',
},
},
{
path: 'loading-lists/:planId',
name: 'loading-list-detail',
component: () => import('./pages/ConfirmedLoadingListDetailView.vue'),
meta: {
title: '装柜清单详情',
description: '查看装柜清单基本信息、货物明细、费用和操作历史',
permission: 'platform:firstleg:loading-list:list',
},
},
] satisfies RouteRecordRaw[]