diff --git a/AGENTS.md b/AGENTS.md index f474d914..102853fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,13 +46,16 @@ | 消息中心 | `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/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/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | +| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | +| 财务、客户资金账户与柜级公共费用分摊 | `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/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/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/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | @@ -68,7 +71,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| -| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `plannedArrivalAt` 升序、空值最后返回仍有待生成计划入仓码的柜。卡片只展示服务端计划统计,详情弹框返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | +| 尾程订单控制与柜级入仓计划转换 | `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/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询、智能预排与转派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 | | 海外出库与派送 | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-development-frontend.acceptance.ts index dc3360be..2a9bb088 100644 --- a/scripts/phase4-development-frontend.acceptance.ts +++ b/scripts/phase4-development-frontend.acceptance.ts @@ -37,6 +37,13 @@ import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersV import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue' import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue' import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue' +import OutboundsView from '@/modules/overseas-warehouse/pages/OutboundsView.vue' +import DeliveryOrdersView from '@/modules/overseas-warehouse/pages/DeliveryOrdersView.vue' +import ExceptionOrdersView from '@/modules/overseas-warehouse/pages/ExceptionOrdersView.vue' +import CustomerVisitsView from '@/modules/overseas-warehouse/pages/CustomerVisitsView.vue' +import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/InventoryTransactionsView.vue' +import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue' +import QueryToolbar from '@/shared/components/QueryToolbar.vue' type Region = 'domestic' | 'overseas' interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean } @@ -238,17 +245,120 @@ async function mountPage(component: Parameters[0], method: string, await waitForTrace(method, path, start) await nextTick() expect(wrapper.text()).not.toContain('服务暂时不可用') + return wrapper } +async function exerciseListSearch( + wrapper: VueWrapper, + path: string, + model: Record, + expectedParams: Record, +) { + const toolbar = wrapper.findComponent(QueryToolbar) + expect(toolbar.exists()).toBe(true) + toolbar.vm.$emit('update:modelValue', { ...model }) + await nextTick() + let start = traces.length + toolbar.vm.$emit('search', { ...model }) + await waitForTrace('GET', path, start) + const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path) + expect(searchRequests).toHaveLength(1) + expect(searchRequests[0]?.httpStatus).toBe(200) + expect(searchRequests[0]?.params).toMatchObject(expectedParams) + expect(wrapper.text()).not.toContain('加载失败') + + start = traces.length + ;(toolbar.vm as unknown as { reset(): void }).reset() + await waitForTrace('GET', path, start) + const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path) + expect(resetRequests).toHaveLength(1) + expect(resetRequests[0]?.httpStatus).toBe(200) + const resetParams = resetRequests[0]?.params as Record | undefined + for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true) + } async function pageRequestCoverage() { - await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes') - await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements') - await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' }) - await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' }) - await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders') - await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans') - await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances') - await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches') - await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications') + const quotes = await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes') + await exerciseListSearch(quotes, '/platform/v1/last-leg/master-data/overseas-quotes', + { keyword: `${marker}-NO-MATCH`, warehouseId: '', providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' }, + { keyword: `${marker}-NO-MATCH`, providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' }) + + const purchases = await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements') + await exerciseListSearch(purchases, '/platform/v1/last-leg/overseas-procurements', + { orderNo: `${marker}-NO-MATCH`, customerId: '', purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` }, + { orderNo: `${marker}-NO-MATCH`, purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` }) + + const trucks = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' }) + await exerciseListSearch(trucks, '/platform/v1/last-leg/us-truck-orders', + { status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` }, + { status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` }) + + const looseboards = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' }) + await exerciseListSearch(looseboards, '/platform/v1/last-leg/local-looseboard-orders', + { status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` }, + { status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` }) + + const lastMile = await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders') + await exerciseListSearch(lastMile, '/platform/v1/last-leg/orders', + { orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2098-01-01', '2098-01-02'] }, + { orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' }) + let start = traces.length + await lastMile.findAll('.el-radio-button').find(item => item.text() === '待分配')!.trigger('click') + await waitForTrace('GET', '/platform/v1/last-leg/orders', start) + start = traces.length + await lastMile.findAll('.el-radio-button').find(item => item.text().startsWith('柜子'))!.trigger('click') + await waitForTrace('GET', '/platform/v1/last-leg/orders/container-summaries', start) + start = traces.length + await lastMile.findAll('.el-radio-button').find(item => item.text() === '全部')!.trigger('click') + await waitForTrace('GET', '/platform/v1/last-leg/orders', start) + + const inbounds = await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans') + await exerciseListSearch(inbounds, '/platform/v1/last-leg/inbound-plans', + { orderNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouse: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', inboundDate: ['2098-01-01', '2098-01-02'] }, + { planNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouseCode: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' }) + + const inventory = await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances') + await exerciseListSearch(inventory, '/platform/v1/last-leg/inventory/balances', + { warehouseId: '', owner: `${marker}-OWNER`, sku: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, location: `${marker}-LOCATION`, status: 'AVAILABLE' }, + { customerName: `${marker}-OWNER`, skuCode: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, warehouseLocationCode: `${marker}-LOCATION`, inventoryStatus: 'AVAILABLE' }) + + const dispatches = await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches') + await exerciseListSearch(dispatches, '/platform/v1/last-leg/dispatches', + { dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` }, + { dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` }) + + const relabels = await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications') + await exerciseListSearch(relabels, '/platform/v1/last-leg/relabel-applications', + { relabelNo: `${marker}-RELABEL`, sku: `${marker}-SKU`, status: 'CANCELLED' }, + { relabelNo: `${marker}-RELABEL`, skuCode: `${marker}-SKU`, status: 'CANCELLED' }) + + const outbounds = await mountPage(OutboundsView, 'GET', '/platform/v1/last-leg/outbound-orders') + await exerciseListSearch(outbounds, '/platform/v1/last-leg/outbound-orders', + { documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' }, + { documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' }) + + const deliveries = await mountPage(DeliveryOrdersView, 'GET', '/platform/v1/last-leg/delivery-orders') + await exerciseListSearch(deliveries, '/platform/v1/last-leg/delivery-orders', + { documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, warehouseId: '', status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedAt: ['2098-01-01', '2098-01-02'] }, + { documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedFrom: '2098-01-01T00:00:00', plannedTo: '2098-01-02T23:59:59' }) + + const exceptions = await mountPage(ExceptionOrdersView, 'GET', '/platform/v1/last-leg/exception-orders') + await exerciseListSearch(exceptions, '/platform/v1/last-leg/exception-orders', + { keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' }, + { keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' }) + + const visits = await mountPage(CustomerVisitsView, 'GET', '/platform/v1/last-leg/customer-visits') + await exerciseListSearch(visits, '/platform/v1/last-leg/customer-visits', + { keyword: `${marker}-VISIT`, status: 'COMPLETED', completedAt: ['2098-01-01', '2098-01-02'] }, + { keyword: `${marker}-VISIT`, status: 'COMPLETED', completedFrom: '2098-01-01T00:00:00', completedTo: '2098-01-02T23:59:59' }) + + const ledger = await mountPage(InventoryTransactionsView, 'GET', '/platform/v1/last-leg/inventory/ledger') + await exerciseListSearch(ledger, '/platform/v1/last-leg/inventory/ledger', + { occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` }, + { customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' }) + + const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/warehouse-admin/pallets') + await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets', + { warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' }, + { keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' }) } describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 4c0fea2e..25a6d45d 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { http } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, + adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, @@ -91,6 +91,17 @@ describe('platform container loading API', () => { expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } }) }) + it('assigns a sealed pallet through the whole-pallet command', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) + + await assignSmartLoadingPallet('10', { palletId: '9007199254740998', expectedVersion: 5 }) + + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments', + { palletId: '9007199254740998', expectedVersion: 5, operationId: expect.stringContaining('assign-pallet-') }, + ) + }) + it('uses the dedicated provisional assignment command for a pending-review order', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index a9454c01..249e211a 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -101,6 +101,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem { id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version), + palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null, + unitType: raw.unitType, indivisible: Boolean(raw.indivisible), packageCount: numberValue(raw.packageCount), pickedPackageCount: numberValue(raw.pickedPackageCount), minPackageCount: numberValue(raw.minPackageCount, 1), @@ -267,7 +269,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue return { ...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId), orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), - coLoadGroupId: optionalId(raw.coLoadGroupId), + coLoadGroupId: optionalId(raw.coLoadGroupId), palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null, + unitType: raw.unitType, indivisible: Boolean(raw.indivisible), memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), locked: raw.locked == null ? null : Boolean(raw.locked), @@ -317,6 +320,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI return plan(unwrapApiResponse(response)) } +export async function assignSmartLoadingPallet(planId: string, payload: { palletId: string; expectedVersion: number }) { + const response = await http.post, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments`, + { ...payload, operationId: operationId('assign-pallet') }, + ) + return plan(unwrapApiResponse(response)) +} + export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) { const response = await http.post, ApiEnvelope>( `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`, diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts new file mode 100644 index 00000000..037d010f --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, +} from './terminalDrayages' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const view = { + containerBookingId: '9007199254740993', containerNo: 'MSCU1234567', oceanBookingId: '9007199254740994', + status: 'UNLOADED', version: '3', fleetSupplierId: '9007199254740995', fleetSupplierName: '港运车队', + pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', + actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', + emptyReturnWarning: { status: 'OVERDUE', deadlineAt: '2026-09-10T10:00:00', overdueHours: '26', overdueDays: '1' }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'], updatedAt: '2026-09-11T12:00:00', +} + +describe('terminal drayages API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sends repeated container ids and preserves long identifiers as strings', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [view] }) + const result = await listTerminalDrayages(['9007199254740993', '9007199254740994']) + const config = vi.mocked(http.get).mock.calls[0][1] as { params: URLSearchParams } + expect(config.params.getAll('containerBookingIds')).toEqual(['9007199254740993', '9007199254740994']) + expect(result[0]).toMatchObject({ containerBookingId: '9007199254740993', oceanBookingId: '9007199254740994', fleetSupplierId: '9007199254740995', version: 3 }) + expect(result[0].emptyReturnWarning).toMatchObject({ overdueHours: 26, overdueDays: 1 }) + }) + + it('uses frozen command paths, versions and idempotency keys', async () => { + vi.mocked(http.post).mockResolvedValue({ data: view }) + await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' }) + await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' }) + await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00' }) + await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00' }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } })) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object)) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5 }), expect.any(Object)) + expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6 }), expect.any(Object)) + }) + + it('loads operation history from the container business id', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: null, operatorId: '9007199254740993', operatorName: '平台管理员', createdAt: '2026-09-07T08:00:00' }] }) + const result = await getTerminalDrayageHistory('101') + expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/101/history') + expect(result[0].operatorId).toBe('9007199254740993') + }) +}) diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts new file mode 100644 index 00000000..10e18da4 --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -0,0 +1,101 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED' +export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' +export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED' + +export interface EmptyReturnWarning { + status: EmptyReturnWarningStatus + deadlineAt: string | null + overdueHours: number + overdueDays: number +} + +export interface TerminalDrayageView { + containerBookingId: string + containerNo: string | null + oceanBookingId: string | null + status: TerminalDrayageStatus + version: number + fleetSupplierId: string | null + fleetSupplierName: string | null + pickupAppointmentAt: string | null + actualPickupAt: string | null + estimatedEmptyReturnAt: string | null + actualEmptyReturnAt: string | null + unloadingCompletedAt: string | null + emptyReturnWarning: EmptyReturnWarning + allowedActions: TerminalDrayageAction[] + updatedAt: string | null +} + +export interface TerminalDrayageHistory { + action: TerminalDrayageAction + fromStatus: string + toStatus: string + fromVersion: number + toVersion: number + reason: string | null + operatorId: string + operatorName: string | null + createdAt: string +} + +const path = '/platform/v1/terminal-drayages' +function operationId(action: string) { return `terminal-drayage-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` } +function id(value: unknown): string { return value == null ? '' : String(value) } +function optionalId(value: unknown): string | null { return value == null ? null : String(value) } +function normalize(raw: TerminalDrayageView): TerminalDrayageView { + return { + ...raw, + containerBookingId: id(raw.containerBookingId), + oceanBookingId: optionalId(raw.oceanBookingId), + fleetSupplierId: optionalId(raw.fleetSupplierId), + version: Number(raw.version || 0), + allowedActions: raw.allowedActions || [], + emptyReturnWarning: { + ...raw.emptyReturnWarning, + overdueHours: Number(raw.emptyReturnWarning?.overdueHours || 0), + overdueDays: Number(raw.emptyReturnWarning?.overdueDays || 0), + }, + } +} + +export async function listTerminalDrayages(containerBookingIds: string[]) { + const params = new URLSearchParams() + containerBookingIds.forEach((containerBookingId) => params.append('containerBookingIds', containerBookingId)) + const response = await http.get, ApiEnvelope>(path, { params }) + return (unwrapApiResponse(response) || []).map(normalize) +} + +export async function getTerminalDrayage(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}`) + return normalize(unwrapApiResponse(response)) +} + +export async function getTerminalDrayageHistory(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}/history`) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) })) +} + +async function command(containerBookingId: string, action: string, body: object) { + const response = await http.post, ApiEnvelope>( + `${path}/${encodeURIComponent(containerBookingId)}/${action}`, + body, + { headers: { 'Idempotency-Key': operationId(action) } }, + ) + return normalize(unwrapApiResponse(response)) +} + +export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) { + return command(containerBookingId, 'fleet', body) +} +export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) { + return command(containerBookingId, 'pickup-appointment', body) +} +export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { + return command(containerBookingId, 'pickup', body) +} +export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { + return command(containerBookingId, 'empty-return', body) +} diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 116af506..a112211e 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -11,6 +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 AppDialog from '@/shared/components/AppDialog.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) @@ -21,6 +22,15 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) +vi.mock('../api/terminalDrayages', () => ({ listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) + +const drayage = { + containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3, + fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', + estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', + emptyReturnWarning: { status: 'OVERDUE' as const, deadlineAt: '2026-09-10T10:00:00', overdueHours: 26, overdueDays: 1 }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'] as const, updatedAt: '2026-09-11T12:00:00', +} function createTestRouter() { return createRouter({ history: createMemoryHistory(), routes: [ @@ -41,6 +51,11 @@ 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(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: [] }) @@ -57,6 +72,9 @@ describe('ContainersView', () => { 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') }) it('keeps the cross-warehouse list usable when warehouse form options fail', async () => { @@ -243,6 +261,46 @@ describe('ContainersView', () => { expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') }) + it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { + 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] } }) + await flushPromises() + + const vm = wrapper.vm as unknown as { + rows: Array> + selectRowFleet: (row: Record, supplierId: string) => Promise + openDrayageAction: (row: Record, action: 'RECORD_EMPTY_RETURN') => void + drayageActionAt: string + saveDrayageAction: () => Promise + } + await vm.selectRowFleet(vm.rows[0], '502') + expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' }) + + vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN') + vm.drayageActionAt = '2026-09-11T13:00:00' + await vm.saveDrayageAction() + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', reason: undefined }) + expect(wrapper.text()).toContain('已还柜') + wrapper.unmount() + }) + + it('loads terminal drayage detail and history only when requested', async () => { + vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }]) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { rows: Array>; openDrayageHistory: (row: Record) => Promise } + await vm.openDrayageHistory(vm.rows[0]) + await flushPromises() + expect(getTerminalDrayage).toHaveBeenCalledWith('9001') + expect(getTerminalDrayageHistory).toHaveBeenCalledWith('9001') + expect(wrapper.text()).toContain('到港安排') + wrapper.unmount() + }) + 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') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index bc578e49..0f6631cd 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -19,6 +19,18 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' +import { + getTerminalDrayage, + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, + type TerminalDrayageAction, + type TerminalDrayageHistory, + type TerminalDrayageView, +} from '../api/terminalDrayages' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -68,9 +80,25 @@ const linkContainerNo = ref('') const linkCandidates = ref>([]) const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) +const drayages = ref>({}) +const drayageLoading = ref(false) +const drayageLoadError = ref(null) +const drayageSavingIds = ref([]) +const drayageActionOpen = ref(false) +const drayageAction = ref>('SCHEDULE_PICKUP') +const drayageTarget = ref(null) +const drayageActionAt = ref('') +const drayageReason = ref('') +const drayageActionSaving = ref(false) +const drayageActionError = ref(null) +const drayageHistoryOpen = ref(false) +const drayageHistory = ref([]) +const drayageHistoryLoading = ref(false) +const drayageHistoryError = ref(null) let listSequence = 0 let detailSequence = 0 let linkSequence = 0 +let drayageSequence = 0 function emptyForm(): ContainerForm { return { @@ -140,6 +168,7 @@ const queryFields = computed(() => [ ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }, + { key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' }, { key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' }, { key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' }, { key: 'planStatus', label: '状态', width: 110, slotName: 'status' }, @@ -150,7 +179,11 @@ const columns: DataTableColumn[] = [ { key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, - { key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 }, + { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, + { key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' }, + { key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' }, + { key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' }, + { key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 }, { key: 'route', label: '航线', minWidth: 220, slotName: 'route' }, { key: 'containerType', prop: 'containerType', label: '柜型', width: 100 }, @@ -163,12 +196,45 @@ const columns: DataTableColumn[] = [ { key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' }, { key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' }, ] +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)}` }, + { key: 'version', label: '版本', width: 100, formatter: (row: TerminalDrayageHistory) => `${row.fromVersion} → ${row.toVersion}` }, + { key: 'operatorName', label: '操作人', width: 130, formatter: (row: TerminalDrayageHistory) => row.operatorName || '—' }, + { key: 'createdAt', label: '操作时间', width: 170, formatter: (row: TerminalDrayageHistory) => formatTime(row.createdAt) }, + { key: 'reason', label: '原因', minWidth: 200 }, +] function optional(value: string | null) { return value?.trim() || null } function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } +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)[status || ''] || status || '—' } +function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record)[action] } +function canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) } +function drayageWarningText(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning || warning.status === 'NOT_APPLICABLE') return '—' + if (warning.status === 'RESOLVED') return '已还柜' + if (warning.status === 'OVERDUE') return warning.overdueDays > 0 ? `超时 ${warning.overdueDays} 天` : `超时 ${warning.overdueHours} 小时` + return '待还柜' +} +function drayageWarningType(row: ContainerBookingResource) { + const status = drayageFor(row.id)?.emptyReturnWarning.status + return status === 'OVERDUE' ? 'danger' : status === 'RESOLVED' ? 'success' : status === 'PENDING' ? 'warning' : 'info' +} +function drayageWarningTitle(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning) return '' + const deadline = warning.deadlineAt ? `还柜截止:${formatTime(warning.deadlineAt)}` : '' + return [drayageWarningText(row), deadline].filter(Boolean).join(';') +} +function localDateTimeValue() { + const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + return now.toISOString().slice(0, 19) +} function containerViewLabel(row: ContainerBookingResource) { const reference = [row.cabinetNo, row.containerNo, row.bookingNo] .find((value) => typeof value === 'string' && value.trim()) @@ -408,6 +474,81 @@ async function saveLink() { } catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') } finally { linkSaving.value = false } } +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) + return value +} +async function selectRowFleet(source: object, supplierIdValue: unknown) { + const row = source as ContainerBookingResource + const current = drayageFor(row.id) + const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || '')) + if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return + drayageSavingIds.value = [...drayageSavingIds.value, row.id] + try { + replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name })) + ElMessage.success('码头车队已更新') + } catch (error) { + const details = getApiErrorDetails(error, '码头车队更新失败') + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + ElMessage.warning('运输信息已被其他人修改,已刷新当前柜最新状态') + } else ElMessage.error(details.message) + } finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) } +} +function openDrayageAction(row: ContainerBookingResource, action: Exclude) { + if (!canDrayage(row, action)) return + drayageTarget.value = row; drayageAction.value = action; drayageActionAt.value = localDateTimeValue(); drayageReason.value = '' + drayageActionError.value = null; drayageActionOpen.value = true +} +async function saveDrayageAction() { + const row = drayageTarget.value + const current = row ? drayageFor(row.id) : null + if (!row || !current || !drayageActionAt.value || !canDrayage(row, drayageAction.value) || drayageActionSaving.value) return + drayageActionSaving.value = true; drayageActionError.value = null + try { + const reason = drayageReason.value.trim() || undefined + const next = drayageAction.value === 'SCHEDULE_PICKUP' + ? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason }) + : drayageAction.value === 'RECORD_PICKUP' + ? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) + : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) + replaceDrayage(next); drayageActionOpen.value = false + ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) + } catch (error) { + const details = getApiErrorDetails(error, `${drayageActionText(drayageAction.value)}失败`) + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + drayageActionError.value = { ...details, message: '运输信息已被其他人修改,已刷新最新状态;请重新确认后提交' } + } else drayageActionError.value = details + } finally { drayageActionSaving.value = false } +} +async function openDrayageHistory(row: ContainerBookingResource) { + drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true + try { + const [current, history] = await Promise.all([getTerminalDrayage(row.id), getTerminalDrayageHistory(row.id)]) + if (drayageTarget.value?.id !== row.id) return + replaceDrayage(current); drayageHistory.value = history + } catch (error) { if (drayageTarget.value?.id === row.id) drayageHistoryError.value = getApiErrorDetails(error, '码头拖车记录加载失败') } + finally { if (drayageTarget.value?.id === row.id) drayageHistoryLoading.value = false } +} async function loadRows() { const sequence = ++listSequence loading.value = true; loadError.value = null @@ -421,6 +562,7 @@ async function loadRows() { }) if (sequence !== listSequence) return rows.value = result.records; total.value = result.total + await loadDrayages(result.records) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -435,6 +577,8 @@ function handleMoreAction(command: string, source: object) { const row = source as ContainerBookingResource if (command === 'link') { void openLink(row); return } 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); return } if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } }) } async function openEdit(source: object) { @@ -534,12 +678,19 @@ onMounted(async () => {
+ 重新加载码头拖车信息 - + + + + + + + @@ -595,12 +746,22 @@ onMounted(async () => { + + + + + + + + + +
diff --git a/src/modules/inbounds/core/api.test.ts b/src/modules/inbounds/core/api.test.ts index e871d88a..345aadc1 100644 --- a/src/modules/inbounds/core/api.test.ts +++ b/src/modules/inbounds/core/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from './api' +import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api' vi.mock('@/shared/api/http', () => ({ ApiResponseError: class ApiResponseError extends Error { @@ -73,6 +73,20 @@ describe('platform inbound query API', () => { expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } }) }) + it('loads and normalizes the inbound-scoped dropship pallet result', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ + palletId: 9007199254740993n, palletNo: 'PLT-001', status: 'SEALED', cartonCount: '2', skuCount: '1', version: '3', + weightKg: '20.5', volumeCbm: '0.88', discrepancyCartons: '1', sealedAt: '2026-09-08T10:00:00', + items: [{ inboundId: 19001, inboundCode: 'RK001', inboundDetailId: 9007199254740994n, skuCode: 'SKU001', cargoName: '服装', cartonCount: '2', skuQuantity: '19', cartonMarks: ['BOX-001', 'BOX-002'] }], + }] }) + + await expect(listPlatformInboundDropshipPallets('10001', '19001')).resolves.toMatchObject([{ + palletId: '9007199254740993', weightKg: 20.5, volumeCbm: 0.88, discrepancyCartons: 1, + items: [{ inboundId: '19001', inboundDetailId: '9007199254740994', cartonCount: 2, skuQuantity: 19, cartonMarks: ['BOX-001', 'BOX-002'] }], + }]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets', { params: { accountId: '10001' } }) + }) + it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 }, @@ -116,4 +130,57 @@ describe('platform inbound query API', () => { message: '箱号映射不完整', code: 'A0422', }) }) + + it('loads and normalizes the tenant-scoped hold state and immutable history', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { + inboundId: 19001, tenantId: 10002, holdFlag: true, currentHoldReason: ' 待核对货款 ', version: '4', + canHold: false, canRelease: true, actionBlockReason: null, + history: [{ id: 88001, action: 'HOLD', reason: ' 待核对货款 ', evidenceFileId: 99001, operatorName: ' 财务A ', operatedAt: '2026-09-07T10:00:00' }], + } }) + + await expect(getPlatformInboundHold('10002', '19001')).resolves.toEqual({ + inboundId: '19001', tenantId: '10002', holdFlag: true, currentHoldReason: '待核对货款', version: 4, + canHold: false, canRelease: true, actionBlockReason: null, + history: [{ id: '88001', action: 'HOLD', reason: '待核对货款', evidenceFileId: '99001', operatorName: '财务A', operatedAt: '2026-09-07T10:00:00' }], + }) + expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001/hold', { params: { tenantId: '10002' } }) + }) + + it('posts hold and release commands with versions, reasons and independent idempotency keys', async () => { + const result = { data: { inboundId: '19001', tenantId: '10002', holdFlag: true, currentHoldReason: '待核对货款', version: 4, canHold: false, canRelease: true, actionBlockReason: null, history: [] } } + vi.mocked(http.post).mockResolvedValue(result) + + await holdPlatformInbound('10002', '19001', { expectedVersion: 3, reason: '待核对货款' }, 'hold-key') + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/inbounds/19001/hold', { expectedVersion: 3, reason: '待核对货款' }, { + params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'hold-key' }, + }) + + await releasePlatformInbound('10002', '19001', { expectedVersion: 4, reason: '货款已核对' }, 'release-key') + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/inbounds/19001/hold/release', { expectedVersion: 4, reason: '货款已核对' }, { + params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'release-key' }, + }) + }) + + it('uploads and downloads tenant-scoped hold evidence without narrowing long IDs', async () => { + const file = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) + vi.mocked(http.post).mockResolvedValueOnce({ data: { + fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: '8', + } }) + await expect(uploadPlatformInboundHoldEvidence('10002', '19001', file)).resolves.toEqual({ + fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: 8, + }) + const [path, body, options] = vi.mocked(http.post).mock.calls[0]! + expect(path).toBe('/platform/inbounds/19001/hold/evidence') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + expect(options).toEqual({ params: { tenantId: '10002' }, headers: { 'Content-Type': 'multipart/form-data' } }) + + const blob = new Blob(['evidence'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValueOnce(blob) + await expect(downloadPlatformInboundHoldEvidence('10002', '19001', '9007199254740999')).resolves.toBe(blob) + expect(http.get).toHaveBeenLastCalledWith( + '/platform/inbounds/19001/hold/evidence/9007199254740999/download', + { params: { tenantId: '10002' }, responseType: 'blob' }, + ) + }) }) diff --git a/src/modules/inbounds/core/api.ts b/src/modules/inbounds/core/api.ts index a7261187..7000ae88 100644 --- a/src/modules/inbounds/core/api.ts +++ b/src/modules/inbounds/core/api.ts @@ -2,12 +2,16 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s import type { InboundFileSummary, InboundInternalNote, + InboundHoldCommand, + InboundHoldDetail, + InboundHoldEvidenceUpload, InboundListQuery, InboundOrderDetail, InboundOrderSummary, InboundPageResult, InboundPalletLocation, InboundStatusHistory, + InboundDropshipPallet, } from './types' const basePath = '/platform/inbounds' @@ -99,11 +103,7 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail { return { ...raw, summary: normalizeInboundSummary(raw.summary), - dropshipPallets: (raw.dropshipPallets || []).map((pallet) => ({ - ...pallet, - palletId: id(pallet.palletId) || '', - items: (pallet.items || []).map((item) => ({ ...item, inboundId: id(item.inboundId) || '', inboundDetailId: id(item.inboundDetailId) || '' })), - })), + dropshipPallets: [], palletLocations: (raw.palletLocations || []).map(normalizePallet), files: (raw.files || []).map(normalizeFile), internalNotes: (raw.internalNotes || []).map(normalizeNote), @@ -111,6 +111,55 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail { } } +function normalizeDropshipPallet(raw: InboundDropshipPallet): InboundDropshipPallet { + return { + ...raw, + palletId: id(raw.palletId) || '', + palletNo: raw.palletNo || null, + cartonCount: integerValue(raw.cartonCount) || 0, + skuCount: integerValue(raw.skuCount) || 0, + version: integerValue(raw.version) || 0, + weightKg: numberValue(raw.weightKg), + volumeCbm: numberValue(raw.volumeCbm), + discrepancyCartons: integerValue(raw.discrepancyCartons) || 0, + sealedAt: raw.sealedAt || null, + items: (raw.items || []).map((item) => ({ + ...item, + inboundId: id(item.inboundId) || '', + inboundDetailId: id(item.inboundDetailId) || '', + cartonCount: integerValue(item.cartonCount) || 0, + skuQuantity: integerValue(item.skuQuantity) || 0, + cartonMarks: item.cartonMarks || [], + })), + } +} + +function normalizeHoldDetail(raw: InboundHoldDetail): InboundHoldDetail { + return { + ...raw, + inboundId: String(raw.inboundId), + tenantId: String(raw.tenantId), + holdFlag: Boolean(raw.holdFlag), + currentHoldReason: raw.currentHoldReason?.trim() || null, + version: integerValue(raw.version) || 0, + canHold: Boolean(raw.canHold), + canRelease: Boolean(raw.canRelease), + actionBlockReason: raw.actionBlockReason?.trim() || null, + history: (raw.history || []).map((item) => ({ + ...item, + id: String(item.id), + reason: item.reason?.trim() || '', + evidenceFileId: id(item.evidenceFileId), + operatorName: item.operatorName?.trim() || null, + })), + } +} + +export function createInboundHoldIdempotencyKey(action: 'hold' | 'release') { + const uuid = globalThis.crypto?.randomUUID?.() + return uuid ? `inbound-${action}-${uuid}` : `inbound-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + function queryParams(tenantId: string | undefined, query: InboundListQuery) { return Object.fromEntries( Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''), @@ -132,6 +181,70 @@ export async function getPlatformInbound(tenantId: string, inboundId: string) { return normalizeDetail(unwrapApiResponse(response)) } +export async function listPlatformInboundDropshipPallets(accountId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + `/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets`, + { params: { accountId } }, + ) + return (unwrapApiResponse(response) || []).map(normalizeDropshipPallet) +} + +export async function getPlatformInboundHold(tenantId: string, inboundId: string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${encodeURIComponent(inboundId)}/hold`, + { params: { tenantId } }, + ) + return normalizeHoldDetail(unwrapApiResponse(response)) +} + +async function postPlatformInboundHold( + tenantId: string, + inboundId: string, + action: 'hold' | 'release', + payload: InboundHoldCommand, + idempotencyKey = createInboundHoldIdempotencyKey(action), +) { + const suffix = action === 'release' ? '/release' : '' + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(inboundId)}/hold${suffix}`, + payload, + { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return normalizeHoldDetail(unwrapApiResponse(response)) +} + +export function holdPlatformInbound(tenantId: string, inboundId: string, payload: InboundHoldCommand, idempotencyKey?: string) { + return postPlatformInboundHold(tenantId, inboundId, 'hold', payload, idempotencyKey) +} + +export function releasePlatformInbound(tenantId: string, inboundId: string, payload: InboundHoldCommand, idempotencyKey?: string) { + return postPlatformInboundHold(tenantId, inboundId, 'release', payload, idempotencyKey) +} + +export async function uploadPlatformInboundHoldEvidence(tenantId: string, inboundId: string, file: File) { + const body = new FormData() + body.append('file', file) + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(inboundId)}/hold/evidence`, + body, + { params: { tenantId }, headers: { 'Content-Type': 'multipart/form-data' } }, + ) + const raw = unwrapApiResponse(response) + return { + fileId: String(raw.fileId), + fileName: String(raw.fileName || file.name), + contentType: String(raw.contentType || file.type || 'application/octet-stream'), + sizeBytes: numberValue(raw.sizeBytes) || 0, + } satisfies InboundHoldEvidenceUpload +} + +export function downloadPlatformInboundHoldEvidence(tenantId: string, inboundId: string, fileId: string) { + return http.get( + `${basePath}/${encodeURIComponent(inboundId)}/hold/evidence/${encodeURIComponent(fileId)}/download`, + { params: { tenantId }, responseType: 'blob' }, + ) +} + export async function downloadInboundCartonLabels(tenantId: string, inboundId: string, cartonIndexes?: number[]) { const response = await http.post( `${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`, diff --git a/src/modules/inbounds/core/types.ts b/src/modules/inbounds/core/types.ts index 9d14394c..c7196f4a 100644 --- a/src/modules/inbounds/core/types.ts +++ b/src/modules/inbounds/core/types.ts @@ -36,6 +36,7 @@ export interface InboundOrderSummary { deliveryWarehouseId: string | null deliveryWarehouseName: string | null businessType: string | null + fulfillmentType: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | string | null channelCode: string | null channelName: string | null taxMode: string | null @@ -124,15 +125,63 @@ export interface InboundOrderDetail { statusHistory: InboundStatusHistory[] } +export interface InboundHoldHistoryEntry { + id: string + action: 'HOLD' | 'RELEASE' + reason: string + evidenceFileId: string | null + operatorName: string | null + operatedAt: string +} + +export interface InboundHoldDetail { + inboundId: string + tenantId: string + holdFlag: boolean + currentHoldReason: string | null + version: number + canHold: boolean + canRelease: boolean + actionBlockReason: string | null + history: InboundHoldHistoryEntry[] +} + +export interface InboundHoldCommand { + expectedVersion: number + reason: string + evidenceFileId?: string +} + +export interface InboundHoldEvidenceUpload { + fileId: string + fileName: string + contentType: string + sizeBytes: number +} + export interface InboundDropshipPallet { palletId: string palletNo: string | null - palletStatus: string + status: 'OPEN' | 'SEALED' cartonCount: number skuCount: number version: number - items: Array<{ inboundId: string; inboundCode: string; inboundDetailId: string; skuCode: string; cargoName: string | null; cartonCount: number; cartonMarks: string[] }> - completedAt: string | null + weightKg: number | null + volumeCbm: number | null + discrepancyCartons: number + sealedAt: string | null + items: InboundDropshipSkuItem[] +} + +export interface InboundDropshipSkuItem { + inboundId: string + inboundCode: string + inboundDetailId: string + skuCode: string + cargoName: string | null + cartonCount: number + skuQuantity: number + cartonMarks: string[] } export interface InboundPageResult { diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index b72aa05a..4a0096b0 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -35,9 +35,11 @@ const snapshot: InboundFeeSnapshot = { carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5, customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓', businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司', - destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG', + destinationCode: 'LAX9', taxMode: 'TAX_REFUND', pricingTaxMode: 'TAX_INCLUDED', chargeMode: 'KG', conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP', - actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6, + actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, + baselineChargeableQuantity: 133.6, lowerChargeableQuantity: 120.24, upperChargeableQuantity: 146.96, + tolerancePercent: 10, chargeableQuantity: 133.6, chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100, totalAmount: 1235.6, currency: 'CNY', rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, @@ -88,6 +90,8 @@ describe('InboundFeePanel', () => { await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88') + expect(document.body.textContent).toContain('订单报关:退税报关') + expect(document.body.textContent).toContain('报价税制:包税') expect(document.body.textContent).not.toContain('租户') expect(document.body.textContent).toContain('头程自动费用 · CNY') expect(document.body.textContent).toContain('¥1,235.60') @@ -237,6 +241,18 @@ describe('InboundFeePanel', () => { expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true) }) + it('allows a published fee adjustment until the customer confirms it', async () => { + const wrapper = mountPanel() + await flushPromises() + expect(wrapper.get('[data-testid="adjust-fee-button"]').text()).toContain('调整已发布费用') + + document.body.innerHTML = '' + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' }) + const confirmed = mountPanel() + await flushPromises() + expect(confirmed.find('[data-testid="adjust-fee-button"]').exists()).toBe(false) + }) + it('confirms and then sends a calculated fee through the implemented backend transitions', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) @@ -294,7 +310,7 @@ describe('InboundFeePanel', () => { expect(adjustPublishedInboundFee).toHaveBeenCalledWith( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用', toleranceConfirmed: true }, expect.stringMatching(/^fee-adjust-/), ) expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) @@ -307,6 +323,11 @@ describe('InboundFeePanel', () => { expect(source).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))') expect(source).toContain('class="adjustment-unit"') expect(source).toContain('white-space: nowrap') + expect(source).toContain('snapshot.value?.lowerChargeableQuantity') + expect(source).toContain('snapshot.value?.upperChargeableQuantity') + expect(source).toContain('toleranceConfirmed: true') + expect(source).toContain("'再次确认计费量调整'") + expect(source).toContain('nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value') }) it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index b51a25ce..28f91214 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -86,11 +86,17 @@ const normalizedInboundId = computed(() => { return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : '' }) const adjustmentEligible = computed(() => ( - snapshot.value?.feeStatus === 'CALCULATED' + ['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '') && snapshot.value?.nextProcessStatus === 'WAIT_SPLIT' + && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED' )) const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费') const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM') +const convertedQuantityLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '体积重量' : '重量折算体积') +const adjustmentBaseline = computed(() => Number(snapshot.value?.baselineChargeableQuantity || 0)) +const adjustmentMinimum = computed(() => Number(snapshot.value?.lowerChargeableQuantity || 0)) +const adjustmentMaximum = computed(() => Number(snapshot.value?.upperChargeableQuantity || 0)) +const adjustmentQuantityChanged = computed(() => Math.abs(Number(adjustmentForm.chargeableQuantity) - adjustmentBaseline.value) > 1e-8) const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus)) const sourceOptions = computed(() => { const value = measurement.value @@ -145,6 +151,12 @@ function taxModeLabel(value: string) { return firstLegTaxModeLabel(value) } +function pricingTaxModeLabel(value: string | null | undefined) { + if (value === 'TAX_INCLUDED') return '包税' + if (value === 'TAX_EXCLUDED') return '不包税' + return '—' +} + function roundingModeLabel(value: string) { const labels: Record = { UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下', @@ -275,6 +287,7 @@ function adjustmentPayload(): AdjustPublishedInboundFeeRequest { unitPrice: Number(adjustmentForm.unitPrice), additionalAmount: Number(adjustmentForm.additionalAmount), reason: adjustmentForm.reason.trim(), + toleranceConfirmed: true, } } @@ -291,6 +304,22 @@ async function submitAdjustment() { if (!snapshot.value) return const valid = await adjustFormRef.value?.validate().catch(() => false) if (!valid) return + const nextQuantity = Number(adjustmentForm.chargeableQuantity) + if (nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value) { + ElMessage.warning(`调整后计费量须在基准值的 ±10% 范围内(${formatNumber(adjustmentMinimum.value, 4)}–${formatNumber(adjustmentMaximum.value, 4)} ${chargeUnitLabel.value})`) + return + } + if (adjustmentQuantityChanged.value) { + try { + await ElMessageBox.confirm( + `计费量将由 ${formatNumber(adjustmentBaseline.value, 4)} 调整为 ${formatNumber(nextQuantity, 4)} ${chargeUnitLabel.value},并生成新费用版本。是否继续?`, + '再次确认计费量调整', + { type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '返回检查' }, + ) + } catch { + return + } + } const payload = adjustmentPayload() adjustmentError.value = null conflictRefreshed.value = false @@ -364,7 +393,7 @@ defineExpose({ reload: loadFee }) data-testid="adjust-fee-button" type="primary" @click="openAdjustment" - >调整初算费用 + >{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}

计费依据

实际重量与体积重量取大后计费,金额以服务端快照为准。

- {{ taxModeLabel(snapshot.taxMode) }} +
+ 订单报关:{{ taxModeLabel(snapshot.taxMode) }} + 报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }} +
实际重量{{ formatNumber(snapshot.actualWeightKg, 3) }} KG
实际体积{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM
-
体积重量{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
+
{{ convertedQuantityLabel }}{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
取大后的计费量{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee }) @confirm="submitAdjustment" > + + +

系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。

+

当前输入仍保留,请先核对最新版本后再提交。

@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
- + {{ chargeUnitLabel }} @@ -571,6 +607,7 @@ defineExpose({ reload: loadFee }) .version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .calculation-section, .fee-timeline { padding: 10px 0; border-bottom: 1px solid var(--cc-border); } .section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.calculation-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } .section-title h4 { font-size: 14px; } .calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 7px; border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .calculation-flow > div { display: flex; min-width: 0; min-height: 46px; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--cc-border); } diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 8c485311..95f7aab9 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -42,7 +42,7 @@ describe('inbound fee API', () => { it('adjusts a published snapshot with the required idempotency key', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } }) - const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' } + const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费', toleranceConfirmed: true as const } await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1')) .resolves.toMatchObject({ feeVersion: 3 }) @@ -78,7 +78,7 @@ describe('inbound fee API', () => { const error = await adjustPublishedInboundFee( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正', toleranceConfirmed: true }, 'fee-adjust-2', ).catch((reason: unknown) => reason) diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index 305d247a..1e3b814b 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -26,6 +26,7 @@ export interface InboundFeeSnapshot { carrierName: string destinationCode: string taxMode: string + pricingTaxMode: string | null chargeMode: InboundChargeMode conversionFactor: number roundingScale: number @@ -33,6 +34,10 @@ export interface InboundFeeSnapshot { actualWeightKg: number actualVolumeCbm: number convertedQuantity: number + baselineChargeableQuantity: number + lowerChargeableQuantity: number + upperChargeableQuantity: number + tolerancePercent: number chargeableQuantity: number chargeUnit: string unitPrice: number | null @@ -58,4 +63,5 @@ export interface AdjustPublishedInboundFeeRequest { unitPrice: number additionalAmount: number reason: string + toleranceConfirmed: true } diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts index 3cff1a9e..0e89cc3a 100644 --- a/src/modules/inbounds/index.ts +++ b/src/modules/inbounds/index.ts @@ -1,4 +1,5 @@ export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api' +export { createInboundHoldIdempotencyKey, downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './core/api' export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue' export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue' export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue' @@ -13,3 +14,4 @@ export { export type { InboundFeeSnapshot } from './fee/types' export { downloadWeightImage } from './weight/api/weightOcr' export type { InboundMeasurementDetail } from './measurement/types' +export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types' diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index 7907255c..fced8f6c 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -89,6 +89,9 @@ function measurement(overrides: Partial = {}): Inbound items: [{ id: '9101', inboundDetailId: '8101', + inboundCode: 'RK202608040001', + fbaShipmentNo: 'FBA18ABC1234', + scannedShipmentId: 'FBA18SCAN5678', boxCode: 'RK202608040001#10-1', itemNo: '001', boxSpecification: '50×40×30cm', @@ -168,6 +171,8 @@ describe('InboundMeasurementPanel', () => { expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001') expect(wrapper.text()).toContain('RK202608040001') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18ABC1234') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678') expect(wrapper.find('.measurement-table').text()).toContain('0.6') expect(wrapper.find('.measurement-table').text()).not.toContain('周长') await wrapper.find('.el-table__expand-icon').trigger('click') diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index f423f66a..3ebab966 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -32,6 +32,9 @@ const emit = defineEmits<{ type EditableMeasurementItem = Omit & { itemId: string clientKey: string + inboundCode: string + fbaShipmentNo: string | null + scannedShipmentId: string | null serverValues: Pick chargeRuleId: string | null coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY' @@ -266,6 +269,9 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem { itemId: String(item.id), inboundDetailId: String(item.inboundDetailId || item.id), clientKey: nextItemKey(), + inboundCode: item.inboundCode || '—', + fbaShipmentNo: item.fbaShipmentNo || null, + scannedShipmentId: item.scannedShipmentId || null, boxCode: item.boxCode, itemNo: item.itemNo, boxSpecification: item.boxSpecification, @@ -701,6 +707,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may + + + + + + + + + diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 52fce114..0f784174 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -13,7 +13,10 @@ export type InboundMeasurementProcessStatus = export interface InboundMeasurementItem { id: MeasurementIdentifier + inboundCode?: string | null inboundDetailId?: MeasurementIdentifier | null + fbaShipmentNo?: string | null + scannedShipmentId?: string | null boxCode: string | null itemNo: string boxSpecification: string diff --git a/src/modules/inbounds/pages/InboundOrdersView.test.ts b/src/modules/inbounds/pages/InboundOrdersView.test.ts index 61151bd9..e194c4ea 100644 --- a/src/modules/inbounds/pages/InboundOrdersView.test.ts +++ b/src/modules/inbounds/pages/InboundOrdersView.test.ts @@ -43,7 +43,7 @@ const summary: InboundOrderSummary = { orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null, notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户', companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null, - deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null, + deliveryWarehouseName: null, businessType: 'FBA', fulfillmentType: 'STANDARD', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null, customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8', holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110, actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01', diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts index 1f46db2f..1b0268ad 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.test.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -48,8 +48,8 @@ const detail = { summary: preloadSummary, items: [{ itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20, - customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, - goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1, + customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, + goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1, dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, }], @@ -75,7 +75,7 @@ describe('platform loading lists API', () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail }) const result = await getPreloadPlan('100', '300') - expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' }) + expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', unitType: 'SEALED_PALLET', palletId: '9007199254740993', palletNo: 'PLT-001', indivisible: true, containerNo: 'MSCU1234567', productQuantity: '20', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true }) expect(result.summary.containerBookingId).toBe('1900000000000000004') expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' }) }) diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts index 8fbc13c8..fcf15b13 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.ts @@ -80,6 +80,8 @@ export interface LoadingItem { itemVersion: number accountId: string customerName: string | null + customsTaxMode: 'TAX_REFUND' | 'BUY_DECLARATION' | null + taxRefundFlag: boolean | null orderId: string orderNo: string inboundId: string @@ -91,6 +93,10 @@ export interface LoadingItem { chineseName: string | null englishName: string | null goodsImageUrl: string | null + unitType: 'PACKAGE' | 'SEALED_PALLET' + palletId: string | null + palletNo: string | null + indivisible: boolean packageCount: number palletCount: number grossWeightKg: number @@ -240,6 +246,9 @@ function normalizeItem(raw: LoadingItem): LoadingItem { orderId: requiredId(raw.orderId), inboundId: requiredId(raw.inboundId), inboundDetailId: requiredId(raw.inboundDetailId), + palletId: optionalId(raw.palletId), + palletNo: raw.palletNo || null, + indivisible: Boolean(raw.indivisible), productQuantity: String(raw.productQuantity ?? '0'), } } diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index cb2ff581..e14f564a 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -28,11 +28,14 @@ const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: const baseItemColumns: DataTableColumn[] = [ { key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true }, + { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 155, className: 'identifier-column', showOverflowTooltip: true }, + { key: 'unitType', label: '装柜单位', width: 110, slotName: 'unitType' }, { key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true }, { key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' }, { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, + { key: 'taxRefundFlag', label: '是否退税', width: 96, align: 'center', slotName: 'taxRefundFlag' }, { key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 }, { key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 }, { key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' }, @@ -212,8 +215,10 @@ function formatNumber(value?: number | null, digits = 3) { + + diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index a2d5c685..3842e1be 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -152,6 +152,7 @@ describe('LoadingListsView', () => { expect(detailSource.indexOf("key: 'orderNo'")).toBeLessThan(detailSource.indexOf("key: 'containerNo'")) expect(detailSource.indexOf("key: 'containerNo'")).toBeLessThan(detailSource.indexOf("key: 'skuCode'")) const fields = [ + 'taxRefundFlag', 'chineseName', 'englishName', 'netWeightKg', diff --git a/src/modules/overseas-warehouse/api/inboundOrders.ts b/src/modules/overseas-warehouse/api/inboundOrders.ts index 210c0cd1..37bee7ee 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.ts @@ -26,6 +26,8 @@ export interface InboundOrderSummaryDto { inboundCode?: string | null lxCode?: string | null orderType?: string | null + overseasWarehouseCode?: string | null + overseasWarehouseName?: string | null warehouseId?: string | null warehouseCode?: string | null warehouseName?: string | null diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index cdbcf131..375ce2ef 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -27,6 +27,7 @@ export interface InboundPlanSummaryDto { inboundCode?: string | null containerNo?: string | null overseasWarehouseId?: string | null + overseasWarehouseCode?: string | null overseasWarehouseName?: string | null destinationWarehouseId?: string | null destinationWarehouseCode?: string | null @@ -47,6 +48,8 @@ export interface InboundPlanDetailDto { customerId: string sourceOrderId: string containerNo?: string | null + overseasWarehouseCode?: string | null + overseasWarehouseName?: string | null status?: string | null lineCount: number palletCount: number diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index 395ceea2..e7f63d5c 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { overseasHttp } from '@/shared/api/overseasHttp' -import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders' +import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) @@ -84,12 +84,32 @@ describe('last-mile order overseas API', () => { }) }) - it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => { + it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => { vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) - await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { - orderId: '190000000000000099', customerId: '190000000000000001', - }) + const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4, + plannedArrivalDate: '2026-09-08', palletCount: 8, palletType: 'STANDARD' as const, + assigned: false, mergeRequired: false, urgent: true, remark: '加急' } + await createManualInboundPlan(payload, 'plan-key-1') + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload, + { headers: { 'Idempotency-Key': 'plan-key-1' } }) + expect(payload).not.toHaveProperty('inboundCode') + expect(payload).not.toHaveProperty('plannedWarehouseId') + expect(payload).not.toHaveProperty('plannedWarehouseCode') + expect(payload).not.toHaveProperty('plannedWarehouseName') + }) + + it('uses versioned idempotent order hold and complete manual-node snapshot endpoints', async () => { + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: {} }) + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { nodes: [] } }) + await updateLastMileOrderHold('190000000000000099', { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, 'hold-key-1') + expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/hold', + { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, { headers: { 'Idempotency-Key': 'hold-key-1' } }) + await getLastMileOrderManualNodes('190000000000000099') + expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes') + const nodes = [{ nodeCode: 'ESTIMATED_ARRIVAL' as const, nodeAt: '2026-09-10T10:00:00' }] + await updateLastMileOrderManualNodes('190000000000000099', 5, nodes, 'node-key-1') + expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes', + { expectedVersion: 5, nodes }, { headers: { 'Idempotency-Key': 'node-key-1' } }) }) it('uses one atomic container request without looping individual conversions', async () => { @@ -128,4 +148,17 @@ describe('last-mile order overseas API', () => { await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail) expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe') }) + + it('changes one whole container receiving warehouse with version and idempotency evidence', async () => { + const updated = { sourceContainerBookingId: '2092873463568597001', overseasWarehouseId: '9007199254740993' } + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: updated }) + await expect(changeContainerReceivingWarehouse('2092873463568597001/unsafe', { + overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜', + }, 'receiving-key-1')).resolves.toEqual(updated) + expect(overseasHttp.put).toHaveBeenCalledWith( + '/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe/receiving-warehouse', + { overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜' }, + { headers: { 'Idempotency-Key': 'receiving-key-1' } }, + ) + }) }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index adb1c3eb..214a168e 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -52,6 +52,8 @@ export interface LastMileOrderSummaryDto { sourceOrderNo?: string | null sourceInboundId?: string | null customerName?: string | null + overseasWarehouseId?: string | null + overseasWarehouseCode?: string | null overseasWarehouseName?: string | null transferWarehouseName?: string | null transferWarehouseId?: string | null @@ -61,6 +63,11 @@ export interface LastMileOrderSummaryDto { containerStatus?: string | null abnormal?: boolean | null detained?: boolean | null + holdChargeable?: boolean | null + holdReason?: string | null + manualOverrideEnabled?: boolean | null + nodeSource?: 'CONTAINER' | 'MANUAL' | null + version?: number | null palletCount?: number | null packageCount?: number | null volumeCbm?: number | null @@ -155,11 +162,6 @@ export interface TransferDestinationPayload { remark?: string } -export interface ConvertInboundPlanPayload { - orderId: string - customerId: string -} - export interface ContainerPlanProgressQuery { page: number size: number @@ -171,6 +173,12 @@ export interface ContainerPlanProgressQuery { export interface ContainerPlanProgressSummaryDto { sourceContainerBookingId: string containerNo: string | null + overseasWarehouseId: string | null + overseasWarehouseCode: string | null + overseasWarehouseName: string | null + assignmentVersion: number + canChangeReceivingWarehouse: boolean + receivingWarehouseLockReason: string | null totalOrderCount: number inboundCodeCount: number missingInboundCodeCount: number @@ -204,8 +212,65 @@ export interface ConvertContainerInboundPlanPayload { sourceContainerBookingId: string } +export type ManualNodeCode = + | 'DOMESTIC_CUSTOMS_COMPLETED' + | 'ESTIMATED_DEPARTURE' + | 'ACTUAL_DEPARTURE' + | 'ESTIMATED_ARRIVAL' + | 'OVERSEAS_CUSTOMS_COMPLETED' + | 'ACTUAL_ARRIVAL_WAITING_UNLOAD' + | 'PICKUP_APPOINTED' + | 'UNLOADED' + +export interface ManualInboundPlanPayload { + customerId: string + orderId: string + expectedOrderVersion: number + plannedArrivalDate: string + palletCount: number + palletType: 'STANDARD' | 'WOOD' | 'PAPER' + assigned: boolean + mergeRequired: boolean + urgent: boolean + remark?: string +} + +export interface HoldLastMileOrderPayload { + held: boolean + chargeable: boolean + reason: string + expectedVersion: number +} + +export interface HoldLastMileOrderResponse { + orderId: string + held: boolean + chargeable: boolean + reason?: string | null + version: number +} + +export interface ManualNodeValue { + nodeCode: ManualNodeCode + nodeAt: string | null +} + +export interface ManualNodesResponse { + orderId: string + manualOverrideEnabled: boolean + nodes: Array + version: number +} + +export interface ChangeContainerReceivingWarehousePayload { + overseasWarehouseId: string + expectedVersion: number + remark?: string +} + export interface LastMileOrderDetailDto { order: LastMileOrderDto + manualNodes?: ManualNodesResponse['nodes'] cargo: LastMileCargoDto[] statusHistory: LastMileStatusHistoryDto[] fees: LastMileFeeDto[] @@ -264,13 +329,6 @@ export async function transferLastMileOrders(payload: TransferDestinationPayload return unwrapApiResponse(response) } -export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundPlanPayload) { - const response = await overseasHttp.post, ApiEnvelope>( - '/platform/v1/last-leg/inbound-plans/convert', payload, - ) - return unwrapApiResponse(response) -} - export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise> { const response = await overseasHttp.get>, ApiEnvelope>>( '/platform/v1/last-leg/orders/container-summaries', @@ -286,6 +344,56 @@ export async function getContainerPlanProgress(sourceContainerBookingId: string) return unwrapApiResponse(response) } +export async function createManualInboundPlan(payload: ManualInboundPlanPayload, idempotencyKey: string) { + const response = await overseasHttp.post, ApiEnvelope>( + '/platform/v1/last-leg/inbound-plans/manual', payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + +export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) { + const response = await overseasHttp.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + +export async function getLastMileOrderManualNodes(orderId: string) { + const response = await overseasHttp.get, ApiEnvelope>( + `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`, + ) + return unwrapApiResponse(response) +} + +export async function updateLastMileOrderManualNodes( + orderId: string, + expectedVersion: number, + nodes: ManualNodeValue[], + idempotencyKey: string, +) { + const response = await overseasHttp.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`, + { expectedVersion, nodes }, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + +export async function changeContainerReceivingWarehouse( + sourceContainerBookingId: string, + payload: ChangeContainerReceivingWarehousePayload, + idempotencyKey: string, +) { + const response = await overseasHttp.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/receiving-warehouse`, + payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { const response = await overseasHttp.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/convert-container', payload, diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index d46b2e5d..e5eec373 100644 --- a/src/modules/overseas-warehouse/api/outbounds.ts +++ b/src/modules/overseas-warehouse/api/outbounds.ts @@ -4,8 +4,19 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder import type { DeliveryOrderRow } from './deliveryOrders' export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null } -export interface OutboundPalletRow { id: string; palletNo?: string | null; cartonMarks?: string | null; skuCode?: string | null; inboundCode?: string | null; packageCount?: number | null; pickedPackageCount?: number | null; status?: string | null } -export interface OutboundDetail { outbound: OutboundRow; delivery?: DeliveryOrderRow | null; pallets?: OutboundPalletRow[] } +export interface OutboundPalletRow { + id?: string | null + palletNo?: string | null + cartonMarks?: string | null + inboundCode?: string | null + destinationWarehouseCode?: string | null + destinationWarehouseName?: string | null + skuCode?: string | null + packageCount?: number | null + pickedPackageCount?: number | null + status?: string | null +} +export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null } export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string } export async function listOutbounds(query: OutboundQuery) { const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/outbound-orders', { params: query }) diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue index 2c497e54..c24c333a 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue @@ -125,6 +125,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string) const id = palletId(item) const target = containers.value.find((container) => container.id === containerId) if (!target || target.palletIds.includes(id)) return + if (!String(item.overseasWarehouseId || '').trim()) { ElMessage.warning('该托盘尚未关联海外仓,不能排车'); return } if (containerFor(id)) { ElMessage.warning('该托盘已在其他装车托盘中'); return } if (target.palletIds.length >= target.capacity) { ElMessage.warning('该装车托盘已达到设置的托数'); return } const nextWeight = containerWeightLb(target) + weightLb(item) @@ -171,6 +172,7 @@ async function close(value = false) { async function submit() { if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘'); return } + if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) { ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘'); return } saving.value = true; mutationError.value = null try { await preArrangeDispatch({ planPalletIds: assignedIds.value, draftToken: ensureDraftToken() }) diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index 9090e149..f4ffa4e9 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -52,6 +52,17 @@ describe('last-mile order presentation adapter', () => { }).owner).toBe('—') }) + it('preserves the frozen overseas receiving warehouse id and code as strings', () => { + const row = toLastMileOrderListItem({ + id: '190000000000000099', customerId: '190000000000000001', orderNo: 'LMO-260908-0001', + overseasWarehouseId: '190000000000000123', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓', + }) + + expect(row.overseasWarehouseId).toBe('190000000000000123') + expect(row.overseasWarehouseCode).toBe('LAX01') + expect(row.warehouse).toBe('洛杉矶接柜仓') + }) + it('preserves the exact server LMO short number without padding, prefixing or deriving it from id', () => { const serverOrderNo = 'LMO-9A7' const row = toLastMileOrderListItem({ id: '2092873463568596994', customerId: '100', orderNo: serverOrderNo }) diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 54680baf..12b98694 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -106,6 +106,8 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), owner: text(dto.customerName), + overseasWarehouseId: String(dto.overseasWarehouseId ?? '').trim(), + overseasWarehouseCode: String(dto.overseasWarehouseCode ?? '').trim(), warehouse: text(dto.overseasWarehouseName), transferWarehouse: text(dto.transferWarehouseName), fbaWarehouse: text(dto.fbaWarehouseCode), @@ -113,6 +115,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO containerStatus: text(dto.containerStatus), abnormal: yesNo(dto.abnormal), held: yesNo(dto.detained), + detained: Boolean(dto.detained), + holdChargeable: Boolean(dto.holdChargeable), + holdReason: String(dto.holdReason ?? '').trim(), + manualNodeOverride: Boolean(dto.manualOverrideEnabled), + version: Number(dto.version ?? 0), pallets: metric(dto.palletCount), cartons: metric(dto.packageCount), volume: metric(dto.volumeCbm), diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts index ac6b95ec..7569d5ec 100644 --- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts +++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts @@ -8,13 +8,13 @@ const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/mod describe('overseas warehouse 1920px layout acceptance', () => { it('keeps action columns first and wide enough for every visible action', () => { const widths: Record = { - 'LastMileOrdersView.vue': 270, - 'InboundOrdersView.vue': 190, - 'DispatchesView.vue': 240, - 'OutboundsView.vue': 190, + 'LastMileOrdersView.vue': 178, + 'InboundOrdersView.vue': 300, + 'DispatchesView.vue': 270, + 'OutboundsView.vue': 180, 'DeliveryOrdersView.vue': 300, - 'RelabelOrdersView.vue': 190, - 'PalletsView.vue': 250, + 'RelabelOrdersView.vue': 180, + 'PalletsView.vue': 180, 'InventoryView.vue': 110, } for (const [name, width] of Object.entries(widths)) { @@ -25,6 +25,14 @@ describe('overseas warehouse 1920px layout acceptance', () => { } }) + it('keeps all inbound-order row actions on one line', () => { + const source = page('InboundOrdersView.vue') + expect(source).toContain('
diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts index 23c2b7f8..7caa4701 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts @@ -17,7 +17,10 @@ describe('DeliveryOrderDetailView', () => { status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00', }, - skus: [], fees: [], operations: [], + skus: [], fees: [ + { id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 }, + { id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 }, + ], operations: [], }) }) @@ -28,6 +31,9 @@ describe('DeliveryOrderDetailView', () => { expect(wrapper.text()).toContain('DEL/1') expect(wrapper.text()).toContain('派送中') expect(wrapper.text()).toContain('洛杉矶仓') + await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click') + expect(wrapper.text()).toContain('私人仓尾程派送费') + expect(wrapper.text()).toContain('派送费用') wrapper.unmount() }) diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue index 64152d13..9a3672e7 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue @@ -24,7 +24,7 @@ const statusLabels: Record = { WAIT_DELIVERY: '待派送', DELIV const methodLabels: Record = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' } const pickupLabels: Record = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' } const loadingLabels: Record = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' } -const feeLabels: Record = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' } +const feeLabels: Record = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' } const operationLabels: Record = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' } const resultLabels: Record = { CREATED: '已创建' } diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue index e354e744..c5766ad4 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue @@ -78,6 +78,6 @@ function reset(model: QueryModel) { page.value = 1; void load(model) } async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } } onMounted(() => { void load(); void loadWarehouses() }) - + diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue index 86f9e72a..fe77a0d9 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue @@ -74,7 +74,7 @@ const fields = computed(() => [ ]) const columns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' }, { key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' }, { key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 }, @@ -216,7 +216,7 @@ onMounted(load)