Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
84 changed files with 3008 additions and 277 deletions
Showing only changes of commit d55cfb3737 - Show all commits
+5 -2
View File
@@ -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/` |
@@ -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<typeof mount>[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<string, unknown>,
expectedParams: Record<string, unknown>,
) {
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<string, unknown> | 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', () => {
@@ -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 })
@@ -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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`,
@@ -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: <T>(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')
})
})
@@ -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<TerminalDrayageView[]>, ApiEnvelope<TerminalDrayageView[]>>(path, { params })
return (unwrapApiResponse(response) || []).map(normalize)
}
export async function getTerminalDrayage(containerBookingId: string) {
const response = await http.get<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(`${path}/${encodeURIComponent(containerBookingId)}`)
return normalize(unwrapApiResponse(response))
}
export async function getTerminalDrayageHistory(containerBookingId: string) {
const response = await http.get<ApiEnvelope<TerminalDrayageHistory[]>, ApiEnvelope<TerminalDrayageHistory[]>>(`${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<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
`${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)
}
@@ -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<Record<string, unknown>>
selectRowFleet: (row: Record<string, unknown>, supplierId: string) => Promise<void>
openDrayageAction: (row: Record<string, unknown>, action: 'RECORD_EMPTY_RETURN') => void
drayageActionAt: string
saveDrayageAction: () => Promise<void>
}
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<Record<string, unknown>>; openDrayageHistory: (row: Record<string, unknown>) => Promise<void> }
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')
@@ -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<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
const drayageLoading = ref(false)
const drayageLoadError = ref<ApiErrorDetails | null>(null)
const drayageSavingIds = ref<string[]>([])
const drayageActionOpen = ref(false)
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
const drayageTarget = ref<ContainerBookingResource | null>(null)
const drayageActionAt = ref('')
const drayageReason = ref('')
const drayageActionSaving = ref(false)
const drayageActionError = ref<ApiErrorDetails | null>(null)
const drayageHistoryOpen = ref(false)
const drayageHistory = ref<TerminalDrayageHistory[]>([])
const drayageHistoryLoading = ref(false)
const drayageHistoryError = ref<ApiErrorDetails | null>(null)
let listSequence = 0
let detailSequence = 0
let linkSequence = 0
let drayageSequence = 0
function emptyForm(): ContainerForm {
return {
@@ -140,6 +168,7 @@ const queryFields = computed<QueryField[]>(() => [
])
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<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record<TerminalDrayageAction, string>)[action] }
function canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) }
function 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<TerminalDrayageAction, 'SELECT_FLEET'>) {
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<TerminalDrayageAction, 'SELECT_FLEET'>); 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 () => {
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<el-alert v-if="drayageLoadError" class="drayage-load-alert" type="warning" :closable="false" show-icon :title="drayageLoadError.message"><el-button link type="warning" :loading="drayageLoading" @click="loadDrayages()">重新加载码头拖车信息</el-button></el-alert>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
@@ -595,12 +746,22 @@ onMounted(async () => {
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form label-position="top"><el-form-item :label="drayageAction === 'SCHEDULE_PICKUP' ? '预约提柜时间' : drayageAction === 'RECORD_PICKUP' ? '实际提柜时间' : '实际还柜时间'" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
@@ -13,6 +13,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
@@ -43,6 +44,7 @@ vi.mock('../api/platformContainerLoading', () => ({
listSmartLoadingWorkbenchContainers: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
assignSmartLoadingPallet: vi.fn(),
assignPendingReviewSmartLoadingOrder: vi.fn(),
assignSmartLoadingGroup: vi.fn(),
recommendSmartLoading: vi.fn(),
@@ -74,7 +76,7 @@ const plan = {
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
@@ -89,7 +91,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page, size, total, pages: total ? Math.ceil(total / size) : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -116,7 +118,8 @@ describe('GlobalSmartLoadingView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
@@ -18,6 +18,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
@@ -97,6 +98,7 @@ const warehouseOptions = computed(() => {
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -114,6 +116,7 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
if (candidate.unitType === 'SEALED_PALLET') return candidate.unitGrossWeightKg
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
@@ -221,20 +224,23 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
{ key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
@@ -490,7 +496,8 @@ function openAssign(source: object) {
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (!plan.value || !selectedCandidate.value) return
if (selectedCandidate.value.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
@@ -498,7 +505,9 @@ async function assign() {
}
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
acceptPlan(selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(plan.value!.id, { palletId: selected.palletId, expectedVersion: plan.value!.version })
: selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.accountId,
orderId: selected.orderId,
@@ -519,6 +528,10 @@ async function assign() {
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
@@ -547,6 +560,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -712,10 +726,10 @@ onMounted(() => { void load(); void loadCustomers() })
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
@@ -789,8 +803,9 @@ onMounted(() => { void load(); void loadCustomers() })
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
@@ -798,7 +813,7 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
@@ -810,6 +825,10 @@ onMounted(() => { void load(); void loadCustomers() })
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
@@ -36,7 +36,7 @@ function candidatePage(records: unknown[]) {
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -46,7 +46,8 @@ describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
@@ -201,4 +202,28 @@ describe('SmartLoadingWorkbenchView', () => {
wrapper.unmount()
})
it('assigns a sealed pallet atomically without a package-count input', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType: 'SEALED_PALLET', palletId: '9007199254740998', palletNo: 'DP-260908-0001',
indivisible: true, availablePackageCount: 12,
}]))
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('DP-260908-0001')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(planResponse.id, { palletId: '9007199254740998', expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -12,7 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
@@ -46,6 +46,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -130,6 +131,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
@@ -140,13 +142,15 @@ const itemColumns: DataTableColumn[] = [
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 110, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 140, slotName: 'unitGrossWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
]
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
@@ -231,12 +235,15 @@ async function assign() {
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
if (!currentPlan || !selected || !inboundId) return
if (selected.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
mutating.value = true
mutationError.value = null
try {
const next = selected.coLoadGroupId
const next = selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(currentPlan.id, { palletId: selected.palletId, expectedVersion: currentPlan.version })
: selected.coLoadGroupId
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
await acceptPlanWithGroups(next)
@@ -246,6 +253,10 @@ async function assign() {
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
@@ -272,6 +283,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -372,24 +384,25 @@ onMounted(load)
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</template>
</template>
</template>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section>
</AdminPage>
</template>
@@ -7,7 +7,7 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40',
orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流',
destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 2, productQuantity: 20,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 2, productQuantity: 20,
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
...overrides,
@@ -15,6 +15,17 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
}
describe('groupSmartLoadingItems', () => {
it('keeps every sealed pallet as an independent non-adjustable allocation unit', () => {
const grouped = groupSmartLoadingItems([
item({ id: 'p1', unitType: 'SEALED_PALLET', palletId: '101', palletNo: 'PLT-101', indivisible: true, packageCount: 4 }),
item({ id: 'p2', unitType: 'SEALED_PALLET', palletId: '102', palletNo: 'PLT-102', indivisible: true, packageCount: 6 }),
])
expect(grouped).toHaveLength(2)
expect(grouped.map((row) => row.palletNo)).toEqual(['PLT-101', 'PLT-102'])
expect(grouped.every((row) => row.canAdjust === false)).toBe(true)
})
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
const grouped = groupSmartLoadingItems([
item(),
@@ -21,7 +21,9 @@ function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem)
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
const groups = new Map<string, SmartLoadingItem[]>()
for (const item of items) {
const key = `${item.accountId}:${item.inboundId}`
const key = item.unitType === 'SEALED_PALLET'
? `${item.accountId}:${item.inboundId}:pallet:${item.palletId}`
: `${item.accountId}:${item.inboundId}:packages`
groups.set(key, [...(groups.get(key) || []), item])
}
@@ -44,7 +46,7 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0),
remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0),
canAdjust: sourceItems.length === 1 && first.canAdjust,
canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust,
canRemove: sourceItems.every((item) => item.canRemove),
sourceItems,
detailCount: sourceItems.length,
@@ -5,7 +5,7 @@ import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
+8
View File
@@ -246,6 +246,10 @@ export interface SmartLoadingCandidate {
destinationWarehouseName: string | null
customerName: string | null
goodsSummary: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
availablePackageCount: number
unitGrossWeightKg: number | null
unitNetWeightKg: number | null
@@ -336,6 +340,10 @@ export interface SmartLoadingItem {
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
packageCount: number
productQuantity: number | null
grossWeightKg: number | null
@@ -20,6 +20,7 @@ import {
const version = {
id: '9007199254740995',
customerId: '2096953508805279745',
versionNo: '2',
originalFileName: '2026-08-price-book.xlsx',
sourceFileId: '9007199254740996',
@@ -88,16 +89,17 @@ const detail = {
describe('platform price-book API', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the platform price-book list endpoint without customer scope and preserves long ids', async () => {
it('uses the selected customer price-book scope and preserves long ids', async () => {
http.get.mockResolvedValue({ data: [version] })
const result = await listPlatformPriceBooks({ status: ' PENDING_CONFIRMATION ' })
const result = await listPlatformPriceBooks({ customerId: '2096953508805279745', status: ' PENDING_CONFIRMATION ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', {
params: { status: 'PENDING_CONFIRMATION' },
params: { customerId: '2096953508805279745', status: 'PENDING_CONFIRMATION' },
})
expect(result[0]).toMatchObject({
id: '9007199254740995',
customerId: '2096953508805279745',
sourceFileId: '9007199254740996',
createdBy: '10001',
versionNo: 2,
@@ -105,12 +107,12 @@ describe('platform price-book API', () => {
})
})
it('omits an empty status filter for the complete version history', async () => {
it('defaults an empty scope to the public price-book history', async () => {
http.get.mockResolvedValue({ data: [] })
await listPlatformPriceBooks({ status: ' ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { params: {} })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { params: { customerId: '0' } })
})
it('uploads one workbook, loads its parsed detail, and normalizes parsed rows', async () => {
@@ -118,10 +120,12 @@ describe('platform price-book API', () => {
http.get.mockResolvedValue({ data: detail })
const file = new File(['book'], 'price-book.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const uploadResult = await uploadPlatformPriceBook(file)
const uploadResult = await uploadPlatformPriceBook(file, '2096953508805279745')
const detailResult = await getPlatformPriceBook('9007199254740995')
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', expect.any(FormData))
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', expect.any(FormData), {
params: { customerId: '2096953508805279745' },
})
const form = http.post.mock.calls[0][1] as FormData
expect(form.get('file')).toBe(file)
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books/9007199254740995')
@@ -11,6 +11,7 @@ type ApiNumber = string | number
export interface PlatformPriceBookVersion {
id: string
customerId: string
versionNo: number
originalFileName: string
sourceFileId: string | null
@@ -96,12 +97,14 @@ export interface PriceBookConfirmationRequest {
}
export interface PlatformPriceBookQuery {
customerId?: string
status?: PriceBookStatus | string
}
interface PriceBookVersionPayload extends Omit<PlatformPriceBookVersion,
'id' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
'id' | 'customerId' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
id: ApiId
customerId: ApiId
versionNo: ApiNumber
sourceFileId?: ApiId | null
channelCount?: ApiNumber | null
@@ -165,6 +168,7 @@ function normalizeVersion(value: PriceBookVersionPayload): PlatformPriceBookVers
return {
...value,
id: String(value.id),
customerId: String(value.customerId),
versionNo: numberValue(value.versionNo),
sourceFileId: nullableId(value.sourceFileId),
channelCount: numberValue(value.channelCount),
@@ -233,8 +237,9 @@ function normalizeDetail(value: PriceBookDetailPayload): PlatformPriceBookDetail
export async function listPlatformPriceBooks(query: PlatformPriceBookQuery = {}): Promise<PlatformPriceBookVersion[]> {
const status = query.status?.trim()
const customerId = query.customerId?.trim() || '0'
const response = await http.get<ApiEnvelope<PriceBookVersionPayload[]>, ApiEnvelope<PriceBookVersionPayload[]>>(basePath, {
params: status ? { status } : {},
params: status ? { customerId, status } : { customerId },
})
return unwrapApiResponse(response).map(normalizeVersion)
}
@@ -246,10 +251,12 @@ export async function getPlatformPriceBook(versionId: string): Promise<PlatformP
return normalizeDetail(unwrapApiResponse(response))
}
export async function uploadPlatformPriceBook(file: File): Promise<PlatformPriceBookDetail> {
export async function uploadPlatformPriceBook(file: File, customerId = '0'): Promise<PlatformPriceBookDetail> {
const form = new FormData()
form.append('file', file)
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(basePath, form)
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(basePath, form, {
params: { customerId: customerId.trim() || '0' },
})
return normalizeDetail(unwrapApiResponse(response))
}
@@ -72,7 +72,16 @@ describe('CarrierRatesView price-book source contract', () => {
expect(source).toContain('.channel-tabs { width: 100%; min-width: 0;')
expect(source).toContain('.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }')
expect(source).not.toContain('tenantId')
expect(source).not.toContain('客户选择')
})
it('supports public and customer-specific price-book scopes without exposing technical ids', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain("customerId: '0'")
expect(source).toContain('平台公共报价')
expect(source).toContain('客户专属')
expect(source).toContain('uploadPlatformPriceBook(file, uploadCustomerId.value)')
expect(source).toContain('只替换同一报价范围')
expect(source).not.toContain('{{ detail.version.customerId }}')
})
it('shows parsing blockers and a channel-tabbed, read-only preview', () => {
@@ -107,9 +116,11 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('uses bordered striped tables and explicit semantic business buttons', () => {
expect(source).toContain('row-key="id"\n border\n stripe')
expect(source).toContain('<DataTable')
expect(source).toContain(':columns="columns"')
expect(source).toContain('border stripe aria-label="渠道价格行预览"')
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
expect(source).toContain('size="small"\n plain\n :loading="downloadingIds.has')
expect(source).toMatch(/size="small"\r?\n\s+plain\r?\n\s+:loading="downloadingIds\.has/)
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<CircleCheck :size="16" />确认并立即生效')
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, CircleCheck, Download, Eye, FileSpreadsheet, FileUp, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -29,6 +30,7 @@ import {
} from '../api/platformPriceBooks'
type PriceBookFilters = QueryModel & {
customerId: string
status: PriceBookStatus | ''
}
@@ -55,6 +57,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
const uploadFileError = ref('')
const uploadCustomerId = ref('0')
const uploadInput = ref<HTMLInputElement>()
const uploading = ref(false)
const detailOpen = ref(false)
@@ -69,15 +72,29 @@ const mappingWarehouses = ref<PlatformWarehouseResource[]>([])
const mappingWarehousesLoaded = ref(false)
const mappingWarehousesLoading = ref(false)
const mappingWarehousesError = ref<ApiErrorDetails | null>(null)
const customerOptions = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const originSelections = ref<Record<string, string>>({})
const overseasSelections = ref<Record<string, string>>({})
const filters = ref<PriceBookFilters>({ status: '' })
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
let loadSequence = 0
let detailSequence = 0
let warehouseSequence = 0
const queryFields: QueryField[] = [
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
label: '报价范围',
placeholder: '请选择报价范围',
props: { 'aria-label': '报价范围', filterable: true, loading: customersLoading.value },
options: [
{ label: '平台公共报价', value: '0' },
...customerOptions.value.map((customer) => ({ label: `客户专属 · ${customer.tenantName || '未命名客户'}`, value: String(customer.id) })),
],
},
{
key: 'status',
type: 'select',
@@ -92,10 +109,11 @@ const queryFields: QueryField[] = [
{ label: '解析失败', value: 'FAILED' },
],
},
]
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
@@ -109,6 +127,7 @@ const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
const pendingCount = computed(() => rows.value.filter((row) => row.status === 'PENDING_CONFIRMATION').length)
const selectedScopeLabel = computed(() => priceBookScopeLabel(appliedFilters.value.customerId))
const loadErrorMessage = computed(() => loadError.value?.message || '')
const isPendingDetail = computed(() => detail.value?.version.status === 'PENDING_CONFIRMATION')
const domesticWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
@@ -222,6 +241,25 @@ function formatEffectivePeriod(version: PlatformPriceBookVersion) {
return version.supersededAt ? `${start} 至 ${formatDateTime(version.supersededAt)}` : `${start} 起`
}
function priceBookScopeLabel(customerId: string | null | undefined) {
if (!customerId || customerId === '0') return '平台公共报价'
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
return customer ? `客户专属 · ${customer.tenantName || '未命名客户'}` : '客户专属报价'
}
async function loadCustomerOptions() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customerOptions.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customerOptions.value = []
ElMessage.warning('客户列表加载失败,仍可维护平台公共报价')
} finally {
customersLoading.value = false
}
}
function formatMoney(value: number, currency: string) {
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
@@ -291,7 +329,7 @@ async function loadPriceBooks() {
loading.value = true
loadError.value = null
try {
const result = await listPlatformPriceBooks({ status: appliedFilters.value.status })
const result = await listPlatformPriceBooks({ customerId: appliedFilters.value.customerId, status: appliedFilters.value.status })
if (sequence !== loadSequence) return
rows.value = result
} catch (error) {
@@ -304,7 +342,10 @@ async function loadPriceBooks() {
}
async function applyQuery(model: QueryModel) {
appliedFilters.value = { status: (model.status || '') as PriceBookStatus | '' }
appliedFilters.value = {
customerId: String(model.customerId || '0'),
status: (model.status || '') as PriceBookStatus | '',
}
page.value = 1
await loadPriceBooks()
}
@@ -317,6 +358,7 @@ function resetUpload() {
function openUpload() {
resetUpload()
uploadCustomerId.value = appliedFilters.value.customerId || '0'
uploadOpen.value = true
}
@@ -477,7 +519,7 @@ async function submitUpload() {
}
uploading.value = true
try {
const result = await uploadPlatformPriceBook(file)
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
uploadOpen.value = false
resetUpload()
await showUploadedDetail(result)
@@ -530,7 +572,7 @@ async function confirmPriceBook() {
if (!detail.value || !canConfirmDetail.value) return
try {
await ElMessageBox.confirm(
'确认后,该价格表会立即成为唯一生效版本,并替换当前生效版本。历史版本和原始文件将继续保留。',
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
@@ -592,6 +634,7 @@ watch(detailOpen, (open) => {
})
onMounted(() => {
void loadCustomerOptions()
void loadPriceBooks()
})
</script>
@@ -650,6 +693,7 @@ onMounted(() => {
</div>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
@@ -669,7 +713,8 @@ onMounted(() => {
@confirm="submitUpload"
>
<div class="upload-content">
<el-alert title="上传后先展示解析结果和仓库映射;确认后才会立即替换当前生效版本。" type="info" :closable="false" show-icon />
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
<label class="upload-scope-field"><span>报价范围</span><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></label>
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
<div class="upload-file-control">
<el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button>
@@ -710,7 +755,7 @@ onMounted(() => {
<DetailPageLayout v-else-if="detail" class="detail-content" aria-label="船期价格表版本详情">
<template #header><header class="detail-summary">
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>{{ priceBookScopeLabel(detail.version.customerId) }} · 版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
<div class="detail-summary__actions"><el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag><el-button :loading="downloadingIds.has(detail.version.id)" @click="downloadSource(detail.version)"><Download :size="16" />下载原文件</el-button></div>
</header></template>
@@ -830,6 +875,8 @@ onMounted(() => {
.issue-counts { color: var(--cc-muted); font-size: 12px; }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
.upload-content { display: grid; gap: 18px; }
.upload-scope-field { display: grid; gap: 6px; color: var(--cc-muted); font-size: 12px; }
.upload-scope-field :deep(.el-select) { width: 100%; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.upload-file-control { display: flex; align-items: center; gap: 14px; }
.upload-file-name { display: grid; min-width: 0; gap: 3px; color: var(--cc-text); font-size: 13px; }
+3 -3
View File
@@ -4,12 +4,12 @@
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
- 正式范围包括客户资金账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。资金账户当前只接充值申请审核和平台手工充值/扣款,冻结/解冻、消费及支付回调后续接入;不要从历史原型自动扩大其他财务功能。
- 正式范围包括客户资金账户、充值申请审核、平台手工充值/扣款、授信额度维护、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。业务冻结/结算/释放由服务端内部接口和跨区事件调用,不在 Admin 页面复制;第三方支付回调尚未接入,不要从历史原型自动扩大其他财务功能。
## 客户资金账户
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
- 平台手工充值和扣款必须提交账户版本与幂等键;扣款不得超过服务端可用余额。页面只展示服务端余额和流水结果,不在浏览器补算或回退静态数据。
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、已用授信、冻结授信和总可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
- 平台手工充值、扣款和授信调整必须提交账户版本与幂等键;扣款及授信下限由服务端校验。页面只展示服务端余额、授信和流水结果,不在浏览器补算或回退静态数据。
## 客户筛选与统计
@@ -105,12 +105,14 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).not.toContain('max-width: 520px')
})
it('renders every account row operation as a small plain primary button with an icon', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 390, fixed: 'left', slotName: 'actions' }")
it('keeps high-frequency account actions visible and groups the remaining actions', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }")
expect(page).toContain('<el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button>')
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
expect(page).toContain('<el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button>')
expect(page).toContain('aria-label="更多客户账户操作"')
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit"><CreditCard :size="15" />授信</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust"><Pencil :size="15" />账期</el-dropdown-item>')
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
expect(page).toContain('.row-actions {')
expect(page).toContain('white-space: nowrap')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, Eye, MinusCircle, Pencil, PlusCircle, Save } from '@lucide/vue'
import { ArrowLeft, CreditCard, Download, Eye, MinusCircle, MoreHorizontal, Pencil, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -81,7 +81,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 390, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
@@ -250,6 +250,10 @@ function openCreditLimit(account: FinanceAccountSummary) {
Object.assign(creditLimitForm, { amount: Number(account.creditLimit || 0).toFixed(2), remark: '' })
creditLimitOpen.value = true
}
function handleAccountMore(command: string, account: FinanceAccountSummary) {
if (command === 'credit') openCreditLimit(account)
if (command === 'terms') void openAccount(account as unknown as Record<string, unknown>, true)
}
async function submitCreditLimit() {
if (!adjustmentAccount.value) return
const raw = creditLimitForm.amount.trim()
@@ -400,7 +404,7 @@ onBeforeUnmount(clearVoucherPreview)
<section v-if="!stateError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button><el-button v-if="canAdjust" size="small" plain type="primary" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')"><PlusCircle :size="15" />充值</el-button><el-button v-if="canAdjust" size="small" plain type="danger" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')"><MinusCircle :size="15" />扣款</el-button><el-button v-if="canAdjust" size="small" plain @click="openCreditLimit(row as FinanceAccountSummary)"><CreditCard :size="15" />授信</el-button><el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button><el-button v-if="canAdjust" size="small" plain type="primary" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')"><PlusCircle :size="15" />充值</el-button><el-button v-if="canAdjust" size="small" plain type="danger" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')"><MinusCircle :size="15" />扣款</el-button><el-dropdown v-if="canAdjust || canUpdateTerms" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button size="small" plain aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit"><CreditCard :size="15" />授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust"><Pencil :size="15" />账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -125,7 +125,7 @@ describe('finance G3/G4 page operations', () => {
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
})
}, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
@@ -19,7 +19,8 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态历史"')
expect(source).toContain('label="其他记录"')
expect(source).toContain("detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'")
expect(source).toContain('listPlatformInboundDropshipPallets(props.customerId, props.inboundId)')
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
@@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import { getPlatformInbound, listPlatformInboundDropshipPallets } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
@@ -37,6 +37,7 @@ let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canViewDropshipPallets = computed(() => access.can('platform:firstleg:pda:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
async function loadDetail() {
@@ -51,6 +52,9 @@ async function loadDetail() {
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (response.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' && canViewDropshipPallets.value) {
response.dropshipPallets = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId)
}
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
@@ -174,12 +178,13 @@ onBeforeUnmount(() => {
:sections="['history']"
/>
</el-tab-pane>
<el-tab-pane label="其他记录" name="records" lazy>
<el-tab-pane :label="detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:dropship-pallets="detail.dropshipPallets"
:customer-id="customerId"
:inbound-id="inboundId"
:can-view-dropship="canViewDropshipPallets"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="[]"
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundHistoryPanel.vue'), 'utf8')
describe('InboundHistoryPanel dropship pallet result', () => {
it('shows the frozen pallet, carton and discrepancy facts', () => {
for (const marker of [
'一件代发清点与组托结果', 'pallet.palletNo', 'pallet.status', 'pallet.cartonCount',
'pallet.skuCount', 'pallet.weightKg', 'pallet.volumeCbm', 'pallet.discrepancyCartons', 'pallet.sealedAt',
'item.inboundCode', 'item.skuCode', 'item.cargoName', 'item.skuQuantity', 'item.cartonCount', 'item.cartonMarks',
]) expect(source).toContain(marker)
expect(source).toContain("SEALED: '已封托'")
})
it('keeps the existing scoped pallet-label action available', () => {
expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)')
expect(source).toContain('打印托盘唛')
})
})
@@ -9,6 +9,7 @@ const props = withDefaults(defineProps<{
dropshipPallets?: InboundDropshipPallet[]
customerId?: string
inboundId?: string
canViewDropship?: boolean
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<{
sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
dropshipPallets: () => [],
canViewDropship: true,
sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'],
})
@@ -30,6 +32,9 @@ function date(value: string | null) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function dropshipPalletStatus(value: string) {
return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record<string, string>)[value] || value || '状态未知'
}
async function printPallet(pallet: InboundDropshipPallet) {
if (!props.customerId || !props.inboundId || !pallet.palletNo) return
try {
@@ -44,9 +49,35 @@ async function printPallet(pallet: InboundDropshipPallet) {
<template>
<div class="history-panel">
<section v-if="shows('dropship')" class="history-section">
<div class="section-heading"><h3>一件代发绑定托盘</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<div v-if="dropshipPallets.length" class="simple-list"><div v-for="pallet in dropshipPallets" :key="pallet.palletId"><strong>{{ value(pallet.palletNo) }} · {{ pallet.cartonCount }} 箱 <el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button></strong><span v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`">{{ item.inboundCode }} · SKU {{ item.skuCode }} · {{ item.cartonCount }} 箱</span></div></div>
<p v-else class="muted-empty">暂无一件代发托盘绑定</p>
<div class="section-heading"><h3>一件代发清点与组托结果</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<el-alert v-if="!canViewDropship" title="当前账号缺少一件代发 PDA 查看权限,无法读取清点组托结果。" type="warning" :closable="false" show-icon />
<div v-else-if="dropshipPallets.length" class="dropship-pallet-list">
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
<div><dt>SKU 数</dt><dd>{{ pallet.skuCount }}</dd></div>
<div><dt>毛重</dt><dd>{{ number(pallet.weightKg) }} kg</dd></div>
<div><dt>体积</dt><dd>{{ number(pallet.volumeCbm) }} m³</dd></div>
<div><dt>差异箱数</dt><dd><el-tag size="small" :type="pallet.discrepancyCartons ? 'warning' : 'success'">{{ pallet.discrepancyCartons }}</el-tag></dd></div>
<div><dt>封托时间</dt><dd>{{ date(pallet.sealedAt) }}</dd></div>
</dl>
<div class="dropship-item-table">
<div class="dropship-item-row dropship-item-row--header"><span>入仓码</span><span>SKU / 货物</span><span>商品数量</span><span>箱数</span><span>箱码</span></div>
<div v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`" class="dropship-item-row">
<span>{{ value(item.inboundCode) }}</span>
<span><strong>{{ value(item.skuCode) }}</strong><small>{{ value(item.cargoName) }}</small></span>
<span>{{ item.skuQuantity }}</span>
<span>{{ item.cartonCount }}</span>
<span>{{ item.cartonMarks.length ? item.cartonMarks.join('、') : '—' }}</span>
</div>
</div>
</article>
</div>
<p v-else class="muted-empty">暂无一件代发清点与组托结果</p>
</section>
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
@@ -93,6 +124,25 @@ async function printPallet(pallet: InboundDropshipPallet) {
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.dropship-pallet-list { display: grid; gap: 12px; }
.dropship-pallet-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.dropship-pallet-card > header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #edf0f4; background: #f8fafc; }
.dropship-pallet-card > header > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.dropship-pallet-card > header strong { overflow-wrap: anywhere; color: #172033; font-size: 13px; }
.dropship-pallet-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border-bottom: 1px solid #edf0f4; }
.dropship-pallet-summary > div { min-width: 0; padding: 8px 12px; border-right: 1px solid #edf0f4; }
.dropship-pallet-summary > div:last-child { border-right: 0; }
.dropship-pallet-summary dt { color: var(--cc-muted); font-size: 11px; }
.dropship-pallet-summary dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 650; }
.dropship-item-table { min-width: 680px; }
.dropship-pallet-card { overflow-x: auto; }
.dropship-item-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.2fr) 100px 70px minmax(220px, 1.5fr); min-width: 800px; border-bottom: 1px solid #edf0f4; }
.dropship-item-row:last-child { border-bottom: 0; }
.dropship-item-row > span { min-width: 0; padding: 8px 12px; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; }
.dropship-item-row > span + span { border-left: 1px solid #edf0f4; }
.dropship-item-row small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.dropship-item-row--header { background: #f8fafc; }
.dropship-item-row--header > span { color: var(--cc-muted); font-size: 11px; font-weight: 650; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
@@ -101,5 +151,6 @@ async function printPallet(pallet: InboundDropshipPallet) {
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 720px) { .dropship-pallet-summary { grid-template-columns: 1fr; } .dropship-pallet-summary > div { border-right: 0; border-bottom: 1px solid #edf0f4; } .dropship-pallet-summary > div:last-child { border-bottom: 0; } }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
+68 -1
View File
@@ -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' },
)
})
})
+118 -5
View File
@@ -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<InboundDropshipPallet[]>, ApiEnvelope<InboundDropshipPallet[]>>(
`/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<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${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<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${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<InboundHoldEvidenceUpload>, ApiEnvelope<InboundHoldEvidenceUpload>>(
`${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<Blob, Blob>(
`${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<Blob, Blob>(
`${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`,
+52 -3
View File
@@ -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 {
@@ -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 () => {
+42 -5
View File
@@ -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<string, string> = {
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"
><PencilLine :size="16" />调整初算费用</el-button>
><PencilLine :size="16" />{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}</el-button>
<el-button
v-if="!displayOnly && canConfirm && snapshot?.feeStatus === 'CALCULATED'"
data-testid="confirm-fee-button"
@@ -452,12 +481,15 @@ defineExpose({ reload: loadFee })
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<div class="calculation-tags">
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
</div>
</div>
<div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>体积重量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div><span>{{ convertedQuantityLabel }}</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div>
<div class="amount-equation" aria-label="费用计算公式">
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee })
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
<el-alert class="adjustment-alert" type="info" :closable="false" show-icon>
<template #title>计费量允许在系统原始基准值 ±{{ formatNumber(snapshot?.tolerancePercent, 2) }}% 内调整</template>
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。</p>
</el-alert>
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
<div class="adjustment-amounts">
<el-form-item label="调整后计费量" prop="chargeableQuantity" required>
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="0.0001" :max="999999999999.9999" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="adjustmentMinimum" :max="adjustmentMaximum" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<span class="adjustment-unit">{{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后单价" prop="unitPrice" required>
@@ -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); }
+2 -2
View File
@@ -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)
+6
View File
@@ -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
}
+2
View File
@@ -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'
@@ -89,6 +89,9 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): 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')
@@ -32,6 +32,9 @@ const emit = defineEmits<{
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
inboundCode: string
fbaShipmentNo: string | null
scannedShipmentId: string | null
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
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
</template>
</el-table-column>
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
</el-table-column>
<el-table-column label="FBA 货件号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.fbaShipmentNo || '—' }}</template>
</el-table-column>
<el-table-column label="设备实扫货件号" min-width="164" show-overflow-tooltip>
<template #default="{ row }">{{ row.scannedShipmentId || '—' }}</template>
</el-table-column>
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
@@ -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
@@ -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',
@@ -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' })
})
@@ -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'),
}
}
@@ -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) {
<template #actions="{ row }">
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
</template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #taxRefundFlag="{ row }">{{ row.taxRefundFlag === true ? '是' : row.taxRefundFlag === false ? '否' : '—' }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
@@ -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',
@@ -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
@@ -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
@@ -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' } },
)
})
})
@@ -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<ManualNodeValue & { source: 'MANUAL' }>
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<unknown>, ApiEnvelope<unknown>>(
'/platform/v1/last-leg/inbound-plans/convert', payload,
)
return unwrapApiResponse(response)
}
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
'/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<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
'/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<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
`/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<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
`/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<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
`/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<ContainerPlanProgressSummaryDto>, ApiEnvelope<ContainerPlanProgressSummaryDto>>(
`/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<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
@@ -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<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
@@ -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() })
@@ -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 })
@@ -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),
@@ -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<string, number> = {
'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('<template #actions="{ row }"><div class="row-actions">')
expect(source).toContain('.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }')
expect(source).toContain('.row-actions :deep(.el-button) { flex: 0 0 auto; }')
expect(source).toContain('.row-actions :deep(.el-button + .el-button) { margin-left: 0; }')
})
it('assigns explicit desktop widths to dense query inputs', () => {
for (const name of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'InventoryView.vue']) {
const source = page(name)
@@ -58,5 +66,13 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-input-number) { width: 100%; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
const lastMileDetail = page('LastMileOrderDetailView.vue')
expect(lastMileDetail).toContain('class="fee-form-grid"')
expect(lastMileDetail).toContain('filterable allow-create clearable default-first-option')
expect(lastMileDetail).toContain('.fee-form { min-width: 0; overflow-x: hidden; }')
expect(lastMileDetail).toContain('.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }')
expect(lastMileDetail).toContain("field.label === '入仓码' || field.label === '集装箱号'")
expect(lastMileDetail).toContain(':title="isSingleLineBusinessField(field) ? String(field.value) : undefined"')
expect(lastMileDetail).toContain('.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
})
})
@@ -163,7 +163,7 @@ onMounted(load)
<div><strong>{{ summary.pending }}</strong><span>待回访</span></div>
<div><strong>{{ summary.completed }}</strong><span>已完成回访</span></div>
</section>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" @search="search" @reset="reset" />
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template>
<template v-if="error" #state><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel">
@@ -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()
})
@@ -24,7 +24,7 @@ const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIV
const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' }
const pickupLabels: Record<string, string> = { 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<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
@@ -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() })
</script>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status" placeholder="请选择派送状态"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入状态备注(选填)" /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入退货原因" /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection" placeholder="请选择退货去向"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" placeholder="请输入退货箱数(选填)" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" placeholder="请输入SKU(选填)" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status" placeholder="请选择派送状态"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入状态备注(选填)" /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入退货原因" /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection" placeholder="请选择退货去向"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" placeholder="请输入退货箱数(选填)" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" placeholder="请输入SKU(选填)" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
<style scoped>.return-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }.return-grid :deep(.el-input),.return-grid :deep(.el-select),.return-grid :deep(.el-input-number){width:100%} @media (max-width: 720px) { .return-grid { grid-template-columns: 1fr; } }</style>
@@ -74,7 +74,7 @@ const fields = computed<QueryField[]>(() => [
])
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)
<template>
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
@@ -77,7 +77,7 @@ onMounted(load)
<template>
<AdminPage><ListPageLayout aria-label="异常订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" @search="search" @reset="reset" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
@@ -13,11 +13,16 @@ const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
const normalizedCode = String(code ?? '').trim()
const normalizedName = String(name ?? '').trim()
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
}
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) }
const firstLine = computed(() => detail.value?.lines[0])
const fieldValues = computed<Record<string, string | number>>(() => ({
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: text(detail.value?.sourceOrderId),
海外仓: text(firstLine.value?.destinationWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: '—',
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
@@ -60,11 +60,11 @@ function selectableRow(source: object) {
const row = source as InboundOrderListItem
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
const anchor = selectedRows.value[0]
return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse)
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: selectableRow },
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
@@ -84,6 +84,11 @@ const columns: DataTableColumn[] = [
]
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
const normalizedCode = String(code ?? '').trim()
const normalizedName = String(name ?? '').trim()
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
}
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待入库', ASSIGNED: '待入库', PDA_PENDING: '待入库', PICKING: '拣货中', COMPLETED: '全部入库', PARTIAL_INBOUND: '部分入库' } as Record<string, string>)[String(value || '')] || text(value) }
@@ -92,8 +97,8 @@ function appointmentStatusLabel(value: unknown) { return !value ? '未预约' :
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
return {
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: text(dto.warehouseName || dto.warehouseCode),
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
@@ -172,18 +177,18 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.warehouse, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
async function submitPlanEdit() { const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
onMounted(() => { void load() })
</script>
<template>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="search" @reset="reset" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
@@ -208,5 +213,8 @@ onMounted(() => { void load() })
.appointment-summary { margin-bottom: 16px; padding: 10px 12px; border: 1px solid #cdddfd; border-radius: 6px; background: #f5f9ff; color: #52627a; font-size: 13px; }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }
.row-actions :deep(.el-button) { flex: 0 0 auto; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>
@@ -64,7 +64,29 @@ describe('inventory customer-name snapshots', () => {
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.find('.overseas-detail-title p').text()).toBe(`入库单号 / 入仓码 / SKU ${balance.inboundOrderNo} / ${balance.inboundCode} / ${balance.skuCode}`)
expect(wrapper.text()).toContain('美本土散板')
expect(wrapper.text()).not.toContain('美本土散版')
expect(wrapper.text()).not.toContain('ONT8目的仓')
expect(wrapper.text()).not.toContain(customerId)
expect(wrapper.text()).not.toContain(balance.id)
})
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 })
routing.route.params = { inventoryId: balance.id }
routing.route.query = { inboundCode: balance.inboundCode }
const inboundCodeWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(inboundCodeWrapper.find('.overseas-detail-title p').text()).toBe(`入仓码 ${balance.inboundCode}`)
expect(inboundCodeWrapper.text()).not.toContain(balance.id)
inboundCodeWrapper.unmount()
vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', inboundCode: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 })
routing.route.query = {}
const emptyWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(emptyWrapper.find('.overseas-detail-title p').text()).toBe('业务标识 —')
expect(emptyWrapper.text()).not.toContain(balance.id)
})
})
@@ -8,9 +8,16 @@ import { listInventoryBalances, listInventoryLedger, listInventoryPositions, typ
const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || ''))
const balance = ref<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
function text(value: unknown) { return String(value ?? '').trim() || '—' }
const businessReferences = computed(() => [
['入库单号', balance.value?.inboundOrderNo || route.query.inboundOrderNo],
['入仓码', balance.value?.inboundCode || route.query.inboundCode],
['SKU', balance.value?.skuCode || route.query.skuCode],
].map(([label, value]) => [String(label), String(value ?? '').trim()] as const).filter(([, value]) => value))
const referenceLabel = computed(() => businessReferences.value.map(([label]) => label).join(' / ') || '业务标识')
const referenceValue = computed(() => businessReferences.value.map(([, value]) => value).join(' / ') || '—')
const productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: text(balance.value?.skuCode), 产品名称: balance.value ? inventoryProductName(balance.value) : '—', 更新时间: formatOverseasDateTime(balance.value?.updatedAt) }))
const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散版: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) }))
const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散板: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) }))
const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散板', '在途库存', '采购库存', '预计总量']
const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' })))
const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) })))
const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) })))
@@ -21,7 +28,7 @@ async function load() { if (!inventoryId.value) { loadError.value = { message: '
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script>
<template><OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template><OverseasDetailShell title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template>
@@ -41,7 +41,7 @@ function search(model: QueryModel) { page.value = 1; void load(model) }; functio
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><el-button link type="primary" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
@@ -34,10 +34,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('basic')
const feeDialogOpen = ref(false)
const feeForm = reactive({ item: '', currency: 'USD', amount: 0, status: 'PENDING_REVIEW', description: '' })
const feeItemOptions = ['仓储费', '入库操作费', '出库操作费', '换标费', '托盘费', '派送费', '转仓费', '异常处理费', '其他']
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
@@ -158,13 +160,13 @@ onMounted(load)
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基本信息" name="basic">
<div class="basic-grid">
<section class="info-panel"><h2>订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="info-panel"><h2>货柜信息</h2><dl class="detail-fields"><div v-for="field in containerFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="info-panel"><h2>订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="info-panel"><h2>货柜信息</h2><dl class="detail-fields"><div v-for="field in containerFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
</div>
</el-tab-pane>
<el-tab-pane label="货物信息" name="cargo"><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"><template #toolbar-start><h2>货物信息</h2></template></DataTable></section></el-tab-pane>
<el-tab-pane label="计划信息" name="plan">
<section class="info-panel plan-panel"><h2>计划信息</h2><dl class="detail-fields plan-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="info-panel plan-panel"><h2>计划信息</h2><dl class="detail-fields plan-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="table-panel related-panel"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></section>
</el-tab-pane>
<el-tab-pane label="费用明细" name="fees"><section class="table-panel"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-start><h2>费用明细</h2></template><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></section></el-tab-pane>
@@ -172,13 +174,13 @@ onMounted(load)
</el-tabs>
</section>
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form label-position="top"><el-row :gutter="20">
<el-col :span="12"><el-form-item label="费用项目" required><el-input v-model="feeForm.item" maxlength="128" placeholder="如 派送费" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="供应商/说明"><el-input v-model="feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="币种" required><el-select v-model="feeForm.currency"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="状态" required><el-select v-model="feeForm.status"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select></el-form-item></el-col>
</el-row></el-form>
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.item" filterable allow-create clearable default-first-option placeholder="选择或输入费用项目"><el-option v-for="item in feeItemOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item label="供应商/说明"><el-input v-model="feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></el-form-item>
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
<el-form-item class="fee-form-full" label="状态" required><el-select v-model="feeForm.status" placeholder="请选择费用状态"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select></el-form-item>
</div></el-form>
</AppDialog>
</OverseasDetailShell>
</template>
@@ -197,11 +199,16 @@ onMounted(load)
.detail-fields > div { display: grid; min-width: 0; min-height: 58px; grid-template-columns: minmax(100px, 34%) minmax(0, 1fr); align-items: center; border-bottom: 1px solid var(--cc-border); }
.detail-fields dt { color: var(--cc-muted); font-size: 13px; }
.detail-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-panel { margin-bottom: 20px; }
.plan-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.plan-fields > div { grid-template-columns: minmax(90px, 42%) minmax(0, 1fr); }
.related-panel { margin-top: 20px; }
.table-panel :deep(.data-table__toolbar) { min-height: 58px; }
.fee-form { min-width: 0; overflow-x: hidden; }
.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 20px; }
.fee-form-full { grid-column: 1 / -1; }
.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }
@media (max-width: 1100px) { .basic-grid { grid-template-columns: 1fr; } .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full { grid-column: auto; } }
</style>
@@ -1,26 +1,37 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
convertLastMileContainerToInboundPlans,
convertLastMileOrderToInboundPlan,
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
transferLastMileOrders,
getLastMileOrderManualNodes,
updateLastMileOrderHold,
updateLastMileOrderManualNodes,
} from '../api/lastMileOrders'
import LastMileOrdersView from './LastMileOrdersView.vue'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
downloadLastMileOrders: vi.fn(),
}))
@@ -37,6 +48,8 @@ const bookingId = '2092873463568597001'
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
overseasWarehouseId: '9007199254740995', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓',
assignmentVersion: 7, canChangeReceivingWarehouse: true, receivingWarehouseLockReason: null,
inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10,
pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false,
blockingReason: '存在5票尚未生成有效入库计划',
@@ -52,8 +65,21 @@ const fbaWarehouse = {
},
addressSummary: 'CA Ontario',
}
const receivingWarehouse = {
...fbaWarehouse,
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
}
const currentReceivingWarehouse = {
...receivingWarehouse,
id: summary.overseasWarehouseId, warehouseCode: summary.overseasWarehouseCode, warehouseName: summary.overseasWarehouseName,
}
function selectedRow(overrides: Record<string, unknown> = {}) {
return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides }
return {
id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001',
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2,
...overrides,
}
}
enableAutoUnmount(afterEach)
@@ -97,6 +123,7 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
describe('LastMileOrdersView pending order and container conversion', () => {
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
@@ -105,8 +132,12 @@ describe('LastMileOrdersView pending order and container conversion', () => {
summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
})
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
})
@@ -124,17 +155,98 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('keeps single-order conversion isolated with long order and customer ids', async () => {
it('forwards complete searches and switches every status tab without stale filter failures', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const model = {
orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG',
warehouse: 'LAX', sourceOrderNo: 'SOURCE-SEARCH', containerNo: 'CONT-SEARCH', transferWarehouseCode: 'ONT8',
status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2026-09-01', '2026-09-07'],
}
toolbar.vm.$emit('search', model)
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith({
page: 1, size: 10, orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG',
warehouse: 'LAX', sourceOrderNo: 'SOURCE-SEARCH', containerNo: 'CONT-SEARCH', transferWarehouseCode: 'ONT8',
currentStatus: 'DELIVERED', abnormal: true, detained: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
const availableFields = toolbar.props('availableFields') as Array<{ key: string; label: string; placeholder?: string }>
expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({
label: 'LX代码', placeholder: '请输入LX代码',
})
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({
page: 1, size: 10, containerNo: 'CONT-SEARCH',
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
const callsBeforeReset = vi.mocked(listLastMileOrders).mock.calls.length
;(toolbar.vm as unknown as { reset(): void }).reset()
await flushPromises()
expect(vi.mocked(listLastMileOrders).mock.calls).toHaveLength(callsBeforeReset + 1)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
expect(vi.mocked(listLastMileOrders).mock.calls.at(-1)?.[0].lxCode).toBeUndefined()
expect(wrapper.text()).not.toContain('尾程订单加载失败')
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓日期')
expect(document.body.textContent).toContain('托盘类型')
const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '订单冻结入仓码')
expect(inboundCodeInput?.props('disabled')).toBe(true)
const receivingWarehouseInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '当前柜接柜仓')
expect(receivingWarehouseInput?.props('disabled')).toBe(true)
expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓')
expect(document.body.textContent).toContain('接柜仓按柜统一')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('shows the order-frozen inbound code but never sends it or a per-order receiving warehouse override', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
const datePicker = wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
datePicker.vm.$emit('update:modelValue', '2026-09-08')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({
customerId, orderId: '2092873463568596994', expectedOrderVersion: 3,
plannedArrivalDate: '2026-09-08', palletCount: 2,
}), expect.stringContaining('manual-inbound-plan-'))
const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record<string, unknown>
expect(payload).not.toHaveProperty('inboundCode')
expect(payload).not.toHaveProperty('plannedWarehouseId')
expect(payload).not.toHaveProperty('plannedWarehouseCode')
expect(payload).not.toHaveProperty('plannedWarehouseName')
})
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openPending()
await openContainerDialog(wrapper)
@@ -155,7 +267,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
@@ -176,29 +288,55 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).toContain(`trace-${code}`)
})
it('renders pending cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => {
const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 }
const completed = { ...summary, sourceContainerBookingId: '2092873463568597003', containerNo: 'CONT-DONE', plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true }
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [completed, summary, nullArrival], page: 1, size: 10, total: 3, pages: 1 })
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 })
const wrapper = await mountView()
const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))
await containerTab!.trigger('click')
await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
expect(wrapper.text()).toContain('柜子工作台(2)')
expect(wrapper.text()).toContain('待处理柜工作台')
expect(wrapper.text()).toContain('按计划到仓时间由近到远排列')
expect(wrapper.text()).toContain('货柜工作台')
expect(wrapper.text()).toContain('按柜查看和修改海外接柜仓,并为剩余入仓码生成独立入库计划')
expect(wrapper.text()).toContain('5个入仓码待生成计划')
expect(wrapper.text()).toContain('共 15 个入仓码')
expect(wrapper.text()).not.toContain('待补入仓码')
expect(wrapper.text()).not.toContain('缺入仓码')
expect(wrapper.text()).not.toContain('已生成 10 个计划')
expect(wrapper.text()).not.toContain('存在5票尚未生成有效入库计划')
expect(wrapper.text()).not.toContain('CONT-DONE')
expect(wrapper.text()).toContain('2 个柜待处理')
expect(wrapper.text()).toContain('2 个货柜')
expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL'))
})
it('keeps an all-plans-ready cabinet visible and allows only the server-authorized warehouse change', async () => {
const ready = {
...summary,
planCount: summary.inboundCodeCount,
plannedInboundCodeCount: summary.inboundCodeCount,
pendingPlanCount: 0,
allPlansReady: true,
blockingReason: null,
}
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [ready], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('CONT-15')
expect(wrapper.text()).toContain('0个入仓码待生成计划')
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
expect((button(wrapper, '修改接柜仓')!.element as HTMLButtonElement).disabled).toBe(false)
})
it('renders the all-cabinet empty state and unit', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 })
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('暂无货柜数据')
expect(wrapper.text()).not.toContain('已全部生成计划的柜不再展示')
})
it('uses independent cabinet pagination and opens the complete long-id detail in a dialog', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 })
const wrapper = await mountView()
@@ -216,6 +354,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(document.body.textContent).toContain('LMO-1')
expect(document.body.textContent).toContain('IN-1')
expect(document.body.textContent).toContain('LIP-1')
expect(document.body.textContent).toContain('已生成入库计划')
expect(document.body.textContent).toContain('待收货')
expect(document.body.textContent).not.toContain('INBOUND_PLANNED')
expect(document.body.textContent).not.toContain('PENDING_RECEIVING')
expect(document.body.querySelector('.el-dialog')).not.toBeNull()
expect(document.body.querySelector('.el-drawer')).toBeNull()
})
@@ -236,6 +378,167 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(getContainerPlanProgress).not.toHaveBeenCalled()
})
it('keeps an invalid historical cabinet readable while blocking plan generation', async () => {
const invalid = {
...summary,
overseasWarehouseId: null,
overseasWarehouseCode: null,
overseasWarehouseName: null,
blockingReason: '柜内接柜仓业务快照不一致',
}
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [invalid], page: 1, size: 10, total: 1, pages: 1 } as never)
vi.mocked(getContainerPlanProgress).mockResolvedValueOnce({ summary: invalid, orders: [] } as never)
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致')
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
await wrapper.get('.container-workbench-card__detail').trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
expect(document.body.textContent).toContain('柜子详情 · CONT-15')
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
it('shows the receiving warehouse and changes it from ACTIVE OVERSEAS master data with versioning', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([currentReceivingWarehouse, receivingWarehouse] as never)
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('海外接柜仓')
expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(wrapper.get('.container-workbench-card__warehouse strong').attributes('title')).toBe('LAX01 · 洛杉矶接柜仓')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
const receivingDialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '修改海外接柜仓')!
expect(receivingDialog.props('width')).toBe('min(600px, calc(100vw - 28px))')
expect(listAllPlatformWarehouses).toHaveBeenLastCalledWith({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
expect(warehouseSelect.props('modelValue')).toBe('')
expect(wrapper.text()).not.toContain('9007199254740995')
const warehouseOptions = wrapper.findAllComponents({ name: 'ElOption' })
expect(warehouseOptions.find((item) => item.props('value') === summary.overseasWarehouseId)?.props('disabled')).toBe(true)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
warehouseSelect.vm.$emit('update:modelValue', summary.overseasWarehouseId)
await wrapper.vm.$nextTick()
expect(confirm.disabled).toBe(true)
expect(wrapper.text()).toContain('新的海外接柜仓必须与当前仓不同')
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
await wrapper.vm.$nextTick()
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(changeContainerReceivingWarehouse).toHaveBeenCalledWith(bookingId, {
overseasWarehouseId: '9007199254740996', expectedVersion: 7, remark: undefined,
}, expect.stringMatching(/^container-receiving-warehouse-/))
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
})
it('keeps the receiving-warehouse confirmation disabled while candidates are loading', async () => {
let resolveCandidates!: (value: typeof receivingWarehouse[]) => void
vi.mocked(listAllPlatformWarehouses).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }) as never)
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
await button(wrapper, '修改接柜仓')!.trigger('click')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
resolveCandidates([receivingWarehouse])
await flushPromises()
expect(confirm.disabled).toBe(true)
})
it('keeps a disabled receiving-warehouse action discoverable when the server locks the container', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({
records: [{ ...summary, canChangeReceivingWarehouse: false, receivingWarehouseLockReason: '海外仓已开始拆柜收货' }],
page: 1, size: 10, total: 1, pages: 1,
})
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('海外仓已开始拆柜收货')
const changeButton = button(wrapper, '修改接柜仓')!
expect(changeButton.exists()).toBe(true)
expect((changeButton.element as HTMLButtonElement).disabled).toBe(true)
expect(changeButton.attributes('title')).toBe('海外仓已开始拆柜收货')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps a disabled receiving-warehouse action discoverable without edit permission', async () => {
canAccess.mockImplementation((permissionCode: string) => permissionCode !== 'platform:overseas-warehouse:last-mile-order:edit')
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
const changeButton = button(wrapper, '修改接柜仓')!
expect(changeButton.exists()).toBe(true)
expect((changeButton.element as HTMLButtonElement).disabled).toBe(true)
expect(changeButton.attributes('title')).toBe('暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限')
await changeButton.trigger('click')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps conflict metadata visible and refreshes the stale container summary', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never)
vi.mocked(changeContainerReceivingWarehouse).mockRejectedValueOnce(new ApiResponseError(
'version conflict', 'A0409', 200, undefined, 'request-receiving', 'trace-receiving',
))
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(wrapper.text()).toContain('接柜仓信息已被其他操作更新,请刷新后重试')
expect(wrapper.text()).toContain('request-receiving')
expect(wrapper.text()).toContain('trace-receiving')
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
})
it('renders an explicit empty state when there are no ACTIVE OVERSEAS receiving warehouses', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('暂无可用的 ACTIVE 海外接柜仓')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps receiving-warehouse candidate failure metadata visible without an FBA fallback', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
'receiving warehouse candidate failed', 'A0503', 200, undefined, 'request-receiving-options', 'trace-receiving-options',
))
const wrapper = await mountView()
await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click')
await flushPromises()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('request-receiving-options')
expect(document.body.textContent).toContain('trace-receiving-options')
expect(document.body.textContent).not.toContain('请选择目的 FBA 仓')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps dense cabinet fields and actions on one line with ellipsis at 1920px', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8')
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Eye } from '@lucide/vue'
import { Download, Eye, Pencil } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -13,11 +13,11 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import { lastMileStatusLabel, toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { saveCsv } from '@/shared/downloadCsv'
@@ -40,6 +40,28 @@ const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false)
const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWarehouseCode, actionTarget.value?.warehouse]
.filter((value) => value && value !== '—')
.join(' · '))
const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'STANDARD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
const manualNodesOpen = ref(false)
const manualNodesLoading = ref(false)
const manualNodesVersion = ref(0)
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
{ code: 'UNLOADED', label: '卸柜' },
]
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
const transferOpen = ref(false)
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
@@ -50,13 +72,21 @@ const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
const receivingWarehouseOpen = ref(false)
const receivingWarehouseLoading = ref(false)
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseActionError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseOptions = ref<TransferWarehouseOption[]>([])
const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(null)
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入领星入库单代码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
@@ -82,6 +112,10 @@ const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
&& row.warehouseId !== receivingWarehouseTarget.value?.overseasWarehouseId
)))
interface TransferWarehouseOption {
warehouseId: string
@@ -112,8 +146,25 @@ function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource):
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
}
}
function receivingWarehouseLabel(container: ContainerPlanProgressSummaryDto | null | undefined) {
if (!container) return '—'
const code = String(container.overseasWarehouseCode || '').trim()
const name = String(container.overseasWarehouseName || '').trim()
return [code, name].filter(Boolean).join(' · ') || '—'
}
function receivingWarehouseChangeDisabledReason(container: ContainerPlanProgressSummaryDto) {
if (!access.can(permission.lastMileOrderTransfer)) return '暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限'
if (!container.canChangeReceivingWarehouse) return container.receivingWarehouseLockReason || '当前柜已锁定,不能修改接柜仓'
return ''
}
function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
@@ -152,17 +203,44 @@ const columns: DataTableColumn[] = [
const containerDetailColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130 },
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130, slotName: 'orderStatus' },
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130, slotName: 'planStatus' },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
]
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function inboundPlanStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({
PENDING_ASSIGNMENT: '待入库',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
PENDING_RECEIVING: '待收货',
PICKING: '拣货中',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
CANCELLED: '已取消',
} as Record<string, string>)[code] || (code ? '未知状态' : '—')
}
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
return Boolean(container.containerNo) && container.pendingPlanCount > 0
return Boolean(!container.allPlansReady
&& container.containerNo
&& container.overseasWarehouseId
&& container.overseasWarehouseCode
&& container.overseasWarehouseName
&& container.assignmentVersion >= 0
&& container.pendingPlanCount > 0)
}
function containerSnapshotBlockReason(container: ContainerPlanProgressSummaryDto) {
if (!container.containerNo) return container.blockingReason || '来源柜真实集装箱号缺失、格式无效或存在漂移'
if (!container.overseasWarehouseId || !container.overseasWarehouseCode
|| !container.overseasWarehouseName || container.assignmentVersion < 0) {
return '柜内接柜仓业务快照不一致'
}
return ''
}
function pendingInboundCodeCount(container: ContainerPlanProgressSummaryDto) {
return Math.max(0, container.inboundCodeCount - container.plannedInboundCodeCount)
@@ -190,9 +268,8 @@ async function load(model: QueryModel = filters.value) {
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
const pendingRecords = result.records.filter((container) => pendingInboundCodeCount(container) > 0)
containerRows.value = pendingRecords
containerTotal.value = Math.max(0, result.total - (result.records.length - pendingRecords.length))
containerRows.value = result.records
containerTotal.value = result.total
} catch (error) {
containerRows.value = []
containerTotal.value = 0
@@ -212,6 +289,7 @@ async function load(model: QueryModel = filters.value) {
orderType: String(filters.value.orderType || '').trim() || undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
lxCode: String(filters.value.lxCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
@@ -255,6 +333,70 @@ async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailLoading.value = false
}
}
async function loadReceivingWarehouseOptions() {
receivingWarehouseLoading.value = true
receivingWarehouseError.value = null
receivingWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
receivingWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
receivingWarehouseError.value = getApiErrorDetails(error, '海外接柜仓候选加载失败,请重试')
} finally {
receivingWarehouseLoading.value = false
}
}
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
const disabledReason = receivingWarehouseChangeDisabledReason(container)
if (disabledReason) {
ElMessage.warning(disabledReason)
return
}
receivingWarehouseTarget.value = container
Object.assign(receivingWarehouseForm, { warehouseId: '', remark: '' })
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
receivingWarehouseOptions.value = []
receivingWarehouseError.value = null
receivingWarehouseActionError.value = null
actionError.value = null
receivingWarehouseOpen.value = true
void loadReceivingWarehouseOptions()
}
async function submitReceivingWarehouseChange() {
const target = receivingWarehouseTarget.value
const selected = selectedReceivingWarehouse.value
if (!target || !selected) {
ElMessage.warning('请选择与当前仓不同的有效海外接柜仓')
return
}
actionLoading.value = true
actionError.value = null
receivingWarehouseActionError.value = null
try {
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
overseasWarehouseId: selected.warehouseId,
expectedVersion: target.assignmentVersion,
remark: receivingWarehouseForm.remark.trim() || undefined,
}, receivingWarehouseIdempotencyKey)
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
ElMessage.success('海外接柜仓修改成功')
await load()
} catch (error) {
const details = getApiErrorDetails(error, '海外接柜仓修改失败,请重试')
receivingWarehouseActionError.value = details.code === 'A0409'
? { ...details, message: '接柜仓信息已被其他操作更新,请刷新后重试' }
: details
actionError.value = receivingWarehouseActionError.value
if (details.code === 'A0409') {
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
await load()
}
} finally {
actionLoading.value = false
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true
@@ -303,26 +445,79 @@ async function submitTransfer() {
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
const selected = selectedRows.value[0]
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
if (!customerId) {
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
function idempotencyKey(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target
Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: typeof target.pallets === 'number' && target.pallets > 0 ? target.pallets : 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
actionError.value = null
inboundPlanOpen.value = true
}
async function submitInboundPlan() {
const row = actionTarget.value
const frozenInboundCode = String(row?.inboundCode || '').trim()
if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < 1) {
ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数')
return
}
actionLoading.value = true
actionError.value = null
actionLoading.value = true; actionError.value = null
try {
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
selectedRows.value = []
ElMessage.success('入库计划生成成功')
await load()
} catch (error) {
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
} finally {
actionLoading.value = false
}
await createManualInboundPlan({
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan'))
inboundPlanOpen.value = false; selectedRows.value = []; ElMessage.success('入库计划已生成'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
}
function openHold(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
actionError.value = null
holdOpen.value = true
}
async function submitHold() {
const row = actionTarget.value
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false }
}
async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
manualNodesOpen.value = true
manualNodesLoading.value = true
actionError.value = null
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
try {
const result = await getLastMileOrderManualNodes(row.id)
manualNodesVersion.value = result.version
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
finally { manualNodesLoading.value = false }
}
async function submitManualNodes() {
const row = actionTarget.value
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
finally { actionLoading.value = false }
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
@@ -398,7 +593,7 @@ onMounted(() => { void load() })
<el-radio-button value="待分配">待分配</el-radio-button>
<el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button>
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
</section>
</template>
<template v-if="loadError" #state>
@@ -411,12 +606,17 @@ onMounted(() => { void load() })
</LoadErrorState>
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel container-workbench" :aria-busy="loading">
<header class="container-workbench__header">
<div><strong>待处理柜工作台</strong><span>按计划到仓时间由近到远排列,已全部生成计划的柜不再展示</span></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个柜待处理</el-tag>
<div><strong>货柜工作台</strong><span>按柜查看和修改海外接柜仓,并为剩余入仓码生成独立入库计划</span></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked" :title="containerSnapshotBlockReason(container)">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked" :title="container.receivingWarehouseLockReason">{{ container.receivingWarehouseLockReason }}</small>
</span>
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
<span class="container-workbench-card__pending">
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
@@ -426,19 +626,22 @@ onMounted(() => { void load() })
</span>
<span class="container-workbench-card__actions">
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
<el-tooltip :content="receivingWarehouseChangeDisabledReason(container)" :disabled="!receivingWarehouseChangeDisabledReason(container)" placement="top">
<span><el-button plain :title="receivingWarehouseChangeDisabledReason(container) || '修改接柜仓'" :disabled="Boolean(receivingWarehouseChangeDisabledReason(container))" @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button></span>
</el-tooltip>
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">生成入库计划</el-button>
</span>
</article>
</div>
<el-empty v-else-if="!loading" description="暂无待处理柜,所有柜均已完成计划分配" />
<el-empty v-else-if="!loading" description="暂无货柜数据" />
<div v-if="containerTotal" class="container-workbench__pagination">
<span>共 {{ containerTotal }} 个待处理柜</span>
<span>共 {{ containerTotal }} 个货柜</span>
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
</div>
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
@@ -478,23 +681,74 @@ onMounted(() => { void load() })
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
</div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #orderStatus="{ row }">{{ lastMileStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).orderStatus) }}</template>
<template #planStatus="{ row }">{{ inboundPlanStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).planStatus) }}</template>
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</template>
</AppDialog>
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
<el-form-item label="新的海外接柜仓" required>
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" :disabled="warehouse.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" />
</el-select>
</el-form-item>
<el-alert v-if="receivingWarehouseForm.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" title="新的海外接柜仓必须与当前仓不同" type="warning" :closable="false" show-icon />
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入修改备注(选填)" /></el-form-item>
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
<template #details><ApiErrorMeta :details="receivingWarehouseActionError" /></template>
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-form class="command-form" label-position="top">
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<el-form class="command-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
</AppDialog>
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.command-form { margin-top: 16px; }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
.manual-node-order { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: 14px; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: 42% 58%; align-items: center; gap: 12px; padding: 10px 14px; }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: 13px; font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: 18px; }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.container-workbench__header > div { display: grid; gap: 4px; }
@@ -504,17 +758,22 @@ onMounted(() => { void load() })
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
.container-workbench-card__arrival { display: grid; gap: 4px; }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
.container-workbench-card__warehouse { display: grid; gap: 4px; }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }
.container-workbench-card__actions :deep(.el-button + .el-button) { margin-left: 0; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -14,6 +14,7 @@ describe('OutboundDetailView', () => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
})
})
@@ -25,10 +26,39 @@ describe('OutboundDetailView', () => {
expect(wrapper.text()).toContain('OUT/1')
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('DEL-1')
expect(wrapper.text()).toContain('出库托盘')
expect(wrapper.text()).toContain('IP-ACY2-20260907-597057-P001')
expect(wrapper.text()).toContain('NYEXX2609060063')
expect(wrapper.text()).toContain('Amazon ONT8')
expect(wrapper.text()).toContain('SKU-001')
expect(wrapper.findAll('.outbound-pallet-table .data-table__single-line-cell').length).toBeGreaterThanOrEqual(6)
expect(wrapper.text()).not.toContain('取消出库单')
wrapper.unmount()
})
it('renders pallet missing values consistently and keeps the empty state in the table', async () => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [{ palletNo: null, inboundCode: null, destinationWarehouseCode: null, destinationWarehouseName: null, skuCode: null, packageCount: null }],
delivery: null,
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.outbound-pallet-table').text()).toContain('—')
expect(wrapper.text()).toContain('暂无关联派送单')
wrapper.unmount()
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [],
delivery: null,
})
const emptyWrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(emptyWrapper.text()).toContain('暂无出库托盘')
emptyWrapper.unmount()
})
it('shows cancellation only for PICKED with the independent permission', async () => {
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
@@ -18,12 +19,21 @@ import { overseasWarehousePermissions as permission } from '../permissions'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const palletRows = computed(() => detail.value?.pallets || [])
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<string, string> = { WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘码', width: 220 },
{ key: 'inboundCode', label: '入仓码', width: 190 },
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
{ key: 'skuCode', label: '商品 SKU', width: 170 },
{ key: 'cartonMarks', label: '箱唛', minWidth: 220 },
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
{ key: 'pickedPackageCount', label: '已拣箱数', width: 110, align: 'right' },
{ key: 'status', label: '状态', width: 110 },
]
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function marks(value: unknown) { return String(value || '').split(',').map((item) => item.trim()).filter(Boolean) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
@@ -36,8 +46,7 @@ watch(outboundNo, () => { void load() }, { immediate: true })
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section>
<section class="surface-panel detail-section"><h2>出库箱唛明细</h2><el-empty v-if="!palletRows.length" description="暂无箱唛快照" :image-size="56" /><el-table v-else :data="palletRows" border size="small"><el-table-column label="托盘" prop="palletNo" min-width="130" /><el-table-column label="入仓单" prop="inboundCode" min-width="130" /><el-table-column label="SKU" prop="skuCode" min-width="120" /><el-table-column label="指定箱唛" min-width="260"><template #default="scope"><el-tag v-for="mark in marks(scope.row.cartonMarks)" :key="mark" size="small" effect="plain" class="mark-tag">{{ mark }}</el-tag><span v-if="!marks(scope.row.cartonMarks).length">—</span></template></el-table-column><el-table-column label="计划/已拣" width="110"><template #default="scope">{{ scope.row.pickedPackageCount ?? 0 }} / {{ scope.row.packageCount ?? 0 }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column></el-table></section></template>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
</DetailPageLayout></AdminPage>
</template>
@@ -28,7 +28,7 @@ const customerOptions = computed(() => {
return [...customers].map(([value, label]) => ({ value, label }))
})
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
@@ -45,7 +45,7 @@ onMounted(load)
<template>
<AdminPage><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" @search="search" @reset="reset" /></template>
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
@@ -41,7 +41,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'code', prop: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', prop: 'type', label: '托盘类型', minWidth: 130 },
{ key: 'warehouse', prop: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', prop: 'volume', label: '容积(m³)', width: 110, align: 'right' },
{ key: 'purchasePrice', prop: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
@@ -37,7 +37,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
{ key: 'sku', label: '商品SKU', minWidth: 150 },
{ key: 'productName', label: '中文品名', minWidth: 180 },
@@ -177,7 +177,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<template>
<AdminPage><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="search" @reset="reset" /></template>
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
@@ -151,7 +151,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan')
expect(inbound).toContain('sourceOrderId')
expect(inbound).not.toContain('detail.value?.sourceOrderId')
expect(inbound).toContain('<LoadErrorState')
const inventory = source('InventoryDetailView.vue')
expect(inventory).toContain('listInventoryPositions')
@@ -198,6 +198,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('createFbaAppointment')
expect(value).toContain('预计装车时间')
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).toContain('anchor.customerId === row.customerId')
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
expect(value).toContain('<Truck :size="16" />智能排车')
expect(value).toContain('type="primary" @click="openSmartDispatch"')
@@ -247,6 +248,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).not.toContain(':model-value="fbaWarehouseCode" readonly')
expect(dialog).toContain("sourceStage: 'RECEIVED'")
expect(dialog).toContain('一次排车只能装同一客户的托盘')
expect(dialog).toContain('该托盘尚未关联海外仓,不能排车')
expect(dialog).toContain('所选托盘中存在尚未关联海外仓的托盘')
expect(dialog).toContain('function ensureDraftToken()')
expect(dialog).toContain('lockDispatchDraftPallet(id, ensureDraftToken(), target.id)')
expect(dialog).toContain('function hydrateDraftPallets()')
@@ -342,14 +345,14 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
])
expect(columnKeys('InboundOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'inboundCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
'pallets', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
])
expect(columnKeys('InventoryView.vue')).toEqual([
@@ -3,16 +3,23 @@ import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import DataTable from '@/shared/components/DataTable.vue'
import { listInboundOrders } from '../api/inboundOrders'
import { getInboundPlan } from '../api/inboundPlans'
import { listInventoryLedger } from '../api/inventory'
import { getLastMileOrder } from '../api/lastMileOrders'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import InboundOrdersView from './InboundOrdersView.vue'
import InventoryTransactionsView from './InventoryTransactionsView.vue'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/inboundOrders', () => ({
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
}))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
@@ -24,11 +31,35 @@ describe('overseas warehouse mounted real API pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
})
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
vi.mocked(listInboundOrders).mockResolvedValue({
records: [
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3' },
{ lineId: '2', planId: '12', planNo: 'IP-2', customerId: '10001', orderId: '22', inboundCode: 'IN-2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', warehouseId: '32', warehouseCode: 'ONT8', warehouseName: 'ONT8目的仓', fbaWarehouseCode: 'ONT8' },
], page: 1, size: 10, total: 2, pages: 1,
})
const wrapper = mount(InboundOrdersView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string }>
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓' })
expect(rows[1]).toMatchObject({ warehouse: '—', fbaWarehouse: 'ONT8', destinationWarehouseName: 'ONT8目的仓' })
})
it('mounts inbound detail with independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
it('shows an empty receiving warehouse when either frozen field is missing without falling back to destination data', async () => {
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
routing.route.params = { orderId: 'IP-2' }; routing.route.query = { customerId: '10001' }
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const field = (label: string) => wrapper.findAll('dt').find((item) => item.text() === label)?.element.parentElement?.querySelector('dd')?.textContent
expect(field('海外仓')).toBe('—')
expect(field('FBA仓库')).toBe('ABE3')
expect(wrapper.text()).not.toContain('ABE3目的仓')
})
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') })
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
})
+8
View File
@@ -14,6 +14,8 @@ export interface LastMileOrderListItem {
inboundCode: string
lxCode: string
owner: string
overseasWarehouseId: string
overseasWarehouseCode: string
warehouse: string
transferWarehouse: string
fbaWarehouse: string
@@ -21,6 +23,11 @@ export interface LastMileOrderListItem {
containerStatus: string
abnormal: string
held: string
detained: boolean
holdChargeable: boolean
holdReason: string
manualNodeOverride: boolean
version: number
pallets: number | '—'
cartons: number | '—'
volume: number | '—'
@@ -53,6 +60,7 @@ export interface InboundOrderListItem {
orderId: string
warehouseId: string
destinationWarehouseCode: string
destinationWarehouseName: string
orderNo: string
inboundCode: string
lxCode: string
@@ -3,6 +3,7 @@ import { http } from '@/shared/api/http'
import {
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
getPlatformUnifiedOrder,
downloadPlatformProductImage,
@@ -73,11 +74,11 @@ describe('unified orders API contract', () => {
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', orderVersion: '7', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', channelCode: ' NY_EXX ', channelName: '纽约渠道', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: '0.4', expectedVolumeCbm: '0.45', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.46', totalPalletCount: '3', totalNetWeightKg: '11.2', chargeableVolumeCbm: '0.5', totalQuantity: '48', skuNos: ['SKU-1', ''], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
records: [{ orderId: '90001', orderVersion: '7', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', channelCode: ' NY_EXX ', channelName: '纽约渠道', holdFlag: true, currentHoldReason: '待核对货款', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: '0.4', expectedVolumeCbm: '0.45', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.46', totalPalletCount: '3', totalNetWeightKg: '11.2', chargeableVolumeCbm: '0.5', totalQuantity: '48', skuNos: ['SKU-1', ''], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', orderVersion: 7, tenantId: '208', inboundId: '77', inboundVersion: 3, channelCode: 'NY_EXX', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: 0.4, expectedVolumeCbm: 0.45, actualGrossWeightKg: 12.5, actualVolumeCbm: 0.46, totalPalletCount: 3, totalNetWeightKg: 11.2, chargeableVolumeCbm: 0.5, totalQuantity: 48, skuNos: ['SKU-1'], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
records: [{ orderId: '90001', orderVersion: 7, tenantId: '208', inboundId: '77', inboundVersion: 3, channelCode: 'NY_EXX', holdFlag: true, currentHoldReason: '待核对货款', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: 0.4, expectedVolumeCbm: 0.45, actualGrossWeightKg: 12.5, actualVolumeCbm: 0.46, totalPalletCount: 3, totalNetWeightKg: 11.2, chargeableVolumeCbm: 0.5, totalQuantity: 48, skuNos: ['SKU-1'], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
})
})
@@ -134,11 +135,39 @@ describe('unified orders API contract', () => {
} })
await expect(batchApprovePlatformUnifiedOrders('2089176869176389634', [
'2089223263733518337', '2089223263733518338',
{ orderId: '2089223263733518337', orderVersion: 3 },
{ orderId: '2089223263733518338', orderVersion: 8 },
])).resolves.toMatchObject({ successCount: 1, failureCount: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
{ items: [
{ orderId: '2089223263733518337', orderVersion: 3 },
{ orderId: '2089223263733518338', orderVersion: 8 },
] },
{ params: { tenantId: '2089176869176389634' } },
)
})
it('loads one batch-review preview with Chinese goods and normalizes long IDs', async () => {
vi.mocked(http.post).mockResolvedValue({ data: {
total: 1,
approvableCount: 1,
items: [{
orderId: '2089223263733518337', orderVersion: '3', inboundCode: 'NYEXX2609080001',
customerId: '2089176869176389634', customerName: '测试客户', chineseGoodsNames: [' 蓝牙音箱 ', ''],
channelCode: 'NY_EXX', channelName: '纽约海运', destinationWarehouseCode: 'ONT8',
destinationWarehouseName: 'ONT8 亚马逊仓', canApprove: true, blockReason: null,
}],
} })
const items = [{ orderId: '2089223263733518337', orderVersion: 3 }]
await expect(getPlatformUnifiedOrderBatchApprovalPreview('2089176869176389634', items)).resolves.toMatchObject({
total: 1, approvableCount: 1,
items: [{ orderId: '2089223263733518337', orderVersion: 3, chineseGoodsNames: ['蓝牙音箱'] }],
})
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-review-preview',
{ items },
{ params: { tenantId: '2089176869176389634' } },
)
})
@@ -119,8 +119,8 @@ export interface UnifiedOrderResource {
updatedAt: string | null
expectedArrivalAt: string | null
feeReviewStatus: string | null
holdCargo?: boolean | null
holdReason?: string | null
holdFlag: boolean | null
currentHoldReason: string | null
companyName?: string | null
businessType?: string | null
pickupMethod?: string | null
@@ -413,6 +413,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
receivingWarehouseId: id(raw.receivingWarehouseId),
destinationWarehouseId: id(raw.destinationWarehouseId),
inboundId: id(raw.inboundId),
holdFlag: raw.holdFlag === null || raw.holdFlag === undefined ? null : Boolean(raw.holdFlag),
currentHoldReason: optionalText(raw.currentHoldReason),
inboundVersion: nullableInteger(raw.inboundVersion),
relation: raw.relation ? {
...raw.relation,
@@ -538,10 +540,59 @@ export interface UnifiedOrderBatchApprovalResult {
failedOrders: Array<{ orderId: number | string; code: string; message: string }>
}
export function batchApprovePlatformUnifiedOrders(tenantId: string, orderIds: string[]) {
export interface UnifiedOrderBatchReviewItem {
orderId: string
orderVersion: number
}
export interface UnifiedOrderBatchApprovalPreviewItem extends UnifiedOrderBatchReviewItem {
inboundCode: string | null
customerId: string | null
customerName: string | null
chineseGoodsNames: string[]
channelCode: string | null
channelName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
canApprove: boolean
blockReason: string | null
}
export interface UnifiedOrderBatchApprovalPreview {
total: number
approvableCount: number
items: UnifiedOrderBatchApprovalPreviewItem[]
}
function normalizeBatchApprovalPreview(result: UnifiedOrderBatchApprovalPreview): UnifiedOrderBatchApprovalPreview {
return {
...result,
total: integer(result.total, 0),
approvableCount: integer(result.approvableCount, 0),
items: (result.items || []).map((item) => ({
...item,
orderId: String(item.orderId),
orderVersion: integer(item.orderVersion, 0),
customerId: id(item.customerId),
chineseGoodsNames: (item.chineseGoodsNames || []).map((name) => name.trim()).filter(Boolean),
canApprove: Boolean(item.canApprove),
})),
}
}
export async function getPlatformUnifiedOrderBatchApprovalPreview(tenantId: string, items: UnifiedOrderBatchReviewItem[]) {
const response = await http.post<ApiEnvelope<UnifiedOrderBatchApprovalPreview>, ApiEnvelope<UnifiedOrderBatchApprovalPreview>>(
'/platform/v1/orders/first-leg/batch-review-preview',
{ items },
{ params: { tenantId } },
)
return normalizeBatchApprovalPreview(unwrapApiResponse(response))
}
export function batchApprovePlatformUnifiedOrders(tenantId: string, items: UnifiedOrderBatchReviewItem[]) {
return http.post<ApiEnvelope<UnifiedOrderBatchApprovalResult>, ApiEnvelope<UnifiedOrderBatchApprovalResult>>(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds },
{ items },
{ params: { tenantId } },
).then(unwrapApiResponse)
}
@@ -18,7 +18,7 @@ import type {
InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
const props = defineProps<{ accountId: string; inboundId: string; destinationType?: string | null }>()
interface OtherFeeSummary { currency: string; approvedReceivable: number; approvedPayable: number }
const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
@@ -74,7 +74,11 @@ const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
{ label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
const privateDeliveryFeeCode = 'PRIVATE_LAST_MILE_DELIVERY'
const applicableItems = computed(() => feeItems.value.filter((item) => {
if (item.feeCode === privateDeliveryFeeCode && props.destinationType !== 'PRIVATE') return false
return form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable
}))
const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
@@ -298,7 +302,7 @@ onMounted(() => void load())
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column label="结算方式" width="120"><template #default="{ row }">{{ settlementLabel(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column label="已付摘要" min-width="290" show-overflow-tooltip><template #default="{ row }">{{ paidSummary(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="费用类型" min-width="190" show-overflow-tooltip><template #default="{ row }"><span>{{ row.feeTypeNameSnapshot }}</span><el-tag v-if="row.feeTypeNameSnapshot === '私人仓尾程派送费'" class="private-delivery-tag" size="small" type="warning" effect="light">仅私人仓</el-tag></template></el-table-column>
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="130" />
<el-table-column prop="currency" label="货币" width="90" />
@@ -314,6 +318,7 @@ onMounted(() => void load())
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="selectedFeeItem?.feeCode === privateDeliveryFeeCode" title="私人仓尾程派送费仅适用于目的仓类型为“私人仓”的订单,最终资格由服务端校验。" type="info" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
@@ -360,6 +365,7 @@ onMounted(() => void load())
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
.private-delivery-tag { margin-left: 6px; vertical-align: middle; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
@@ -26,6 +26,7 @@ const rows: UnifiedOrderResource[] = ['5001', '5002'].map((orderId, index) => ({
relation: null, internalOrderStatus: 'WAIT_SPLIT', customerId: `600${index}`, customerName: `客户${index + 1}`,
channelCode: 'NY_EXX', channelName: '纽约渠道', receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8',
inboundId: `700${index}`, inboundVersion: 1, inboundCode: `IN-${index + 1}`, expectedShipDate: null, totalPackageCount: 5,
holdFlag: false, currentHoldReason: null,
locationCodes: null, actualGrossWeightKg: 10, actualVolumeCbm: 1, measurement: null, preload: null, loading: null, delivery: null, transport: null,
createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null, totalPalletCount: null,
chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null, measurementCompletedAt: null, inboundCompletedAt: null,
@@ -1,18 +1,21 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import { getPlatformUnifiedOrder } from '@/modules/unified-orders/api/unifiedOrders'
import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import { downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from '@/modules/inbounds'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { orderId: '5001' }, query: { tenantId: '6001' } }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const { canPermission } = vi.hoisted(() => ({ canPermission: vi.fn((..._permissions: string[]) => true) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
downloadPlatformInboundHoldEvidence: vi.fn(), getPlatformInboundHold: vi.fn(), holdPlatformInbound: vi.fn(), releasePlatformInbound: vi.fn(), uploadPlatformInboundHoldEvidence: vi.fn(),
InboundFeePanel: { template: '<div />' }, InboundMeasurementDialog: { template: '<div />' }, MeasurementEvidenceThumbnail: { template: '<div />' },
}))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
@@ -24,6 +27,7 @@ vi.mock('@/shared/api/inboundCodeRelations', () => ({
}))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
downloadPlatformProductImage: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [],
}))
@@ -34,6 +38,7 @@ const detail = {
internalOrderStatus: 'WAIT_SPLIT', customerId: '6001', customerName: '测试客户', channelName: '纽约渠道',
receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8',
inboundId: '7001', inboundVersion: 1, inboundCode: 'NYEXX2608310001', expectedShipDate: null, totalPackageCount: 2,
holdFlag: false, currentHoldReason: null,
locationCodes: null, actualGrossWeightKg: null, actualVolumeCbm: null, measurement: null, preload: null, loading: null, delivery: null, transport: null,
coLoadGroup: null, createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null,
totalPalletCount: null, chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null,
@@ -56,6 +61,11 @@ function mountDetail() {
ApiErrorMeta: true,
StatusBadge: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-table': { props: ['data'], template: '<div><slot /></div>' },
'el-table-column': { template: '<div />' },
'el-tag': { template: '<span><slot /></span>' },
'el-empty': { props: ['description'], template: '<div>{{ description }}</div>' },
'el-alert': { props: ['title'], template: '<div>{{ title }}</div>' },
},
},
})
@@ -64,7 +74,27 @@ function mountDetail() {
describe('UnifiedOrdersView downstream fulfillment detail', () => {
beforeEach(() => {
vi.clearAllMocks()
canPermission.mockImplementation(() => true)
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
vi.mocked(downloadPlatformProductImage).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
vi.mocked(uploadPlatformInboundHoldEvidence).mockResolvedValue({
fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: 8,
})
vi.mocked(downloadPlatformInboundHoldEvidence).mockResolvedValue(new Blob(['evidence'], { type: 'application/pdf' }))
vi.mocked(getPlatformInboundHold).mockResolvedValue({
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3,
canHold: true, canRelease: false, actionBlockReason: null,
history: [{ id: '8801', action: 'RELEASE', reason: '货款已核对', evidenceFileId: '9901', operatorName: '操作员A', operatedAt: '2026-09-07T10:00:00' }],
})
vi.mocked(holdPlatformInbound).mockResolvedValue({
inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4,
canHold: false, canRelease: true, actionBlockReason: null, history: [],
})
vi.mocked(releasePlatformInbound).mockResolvedValue({
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 4,
canHold: true, canRelease: false, actionBlockReason: null, history: [],
})
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: null, loadingPlanNo: null,
containerBookingId: null, cabinetNo: null, containerNo: null, loadingStatus: null, taskId: null,
@@ -76,6 +106,20 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
})
})
it('downloads one preview for cargo rows sharing the same product image', async () => {
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({
...detail,
order: {
...detail.order,
cargoes: [1, 2, 3, 4].map((itemNo) => ({ itemNo, productImageFileId: '9901' })),
},
} as never)
mountDetail()
await flushPromises()
expect(downloadPlatformProductImage).toHaveBeenCalledTimes(1)
expect(downloadPlatformProductImage).toHaveBeenCalledWith('6001', '9901')
})
it('shows a compact empty state in order detail when no downstream relationship exists', async () => {
const wrapper = mountDetail()
await flushPromises()
@@ -112,4 +156,273 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
expect(wrapper.text()).not.toContain('9001')
expect(wrapper.text()).not.toContain('9004')
})
it('loads server hold capabilities and history only after opening the other tab', async () => {
const wrapper = mountDetail()
await flushPromises()
expect(getPlatformInboundHold).not.toHaveBeenCalled()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001')
expect(wrapper.text()).toContain('未扣货')
const loaded = (wrapper.vm as unknown as { holdDetail: { history: Array<{ reason: string; evidenceFileId: string }> } }).holdDetail
expect(loaded.history).toEqual([expect.objectContaining({ reason: '货款已核对', evidenceFileId: '9901' })])
expect(wrapper.findAll('button').map((button) => button.text())).toContain('扣货')
})
it('submits the required reason and server version, then refreshes detail and list', async () => {
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as { holdReason: string; openHoldDialog(action: 'HOLD'): void; submitHoldAction(): Promise<void> }
vm.openHoldDialog('HOLD')
vm.holdReason = ' 等待客户补款 '
await vm.submitHoldAction()
expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款' })
expect(getPlatformUnifiedOrder).toHaveBeenCalledTimes(2)
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
})
it('exposes loading, empty and error states for the lazy hold request', async () => {
let resolveHold!: (value: Awaited<ReturnType<typeof getPlatformInboundHold>>) => void
vi.mocked(getPlatformInboundHold).mockImplementationOnce(() => new Promise((resolve) => { resolveHold = resolve }))
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
expect((wrapper.vm as unknown as { holdLoading: boolean }).holdLoading).toBe(true)
resolveHold({
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3,
canHold: true, canRelease: false, actionBlockReason: null, history: [],
})
await flushPromises()
const loadedVm = wrapper.vm as unknown as { holdLoading: boolean; holdDetail: { history: unknown[] } }
expect(loadedVm.holdLoading).toBe(false)
expect(loadedVm.holdDetail.history).toEqual([])
expect(wrapper.text()).toContain('扣货历史')
vi.mocked(getPlatformInboundHold).mockRejectedValueOnce(new Error('网络断开'))
await (wrapper.vm as unknown as { loadHoldDetail(force: boolean): Promise<void> }).loadHoldDetail(true)
expect(wrapper.text()).toContain('扣货状态与历史加载失败')
expect((wrapper.vm as unknown as { holdError: { message: string } }).holdError.message).toBe('扣货状态与历史加载失败')
})
it('rejects blank and over-limit reasons while accepting exactly 1000 characters', async () => {
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as {
holdReason: string
openHoldDialog(action: 'HOLD'): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('HOLD')
vm.holdReason = ' '
await vm.submitHoldAction()
vm.holdReason = 'a'.repeat(1001)
await vm.submitHoldAction()
expect(holdPlatformInbound).not.toHaveBeenCalled()
vm.holdReason = 'a'.repeat(1000)
await vm.submitHoldAction()
expect(holdPlatformInbound).toHaveBeenCalledOnce()
expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', {
expectedVersion: 3,
reason: 'a'.repeat(1000),
})
})
it('prevents duplicate submissions while the hold request is pending', async () => {
let resolveCommand!: (value: Awaited<ReturnType<typeof holdPlatformInbound>>) => void
vi.mocked(holdPlatformInbound).mockImplementationOnce(() => new Promise((resolve) => { resolveCommand = resolve }))
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as {
holdReason: string
holdSaving: boolean
openHoldDialog(action: 'HOLD'): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('HOLD')
vm.holdReason = '等待客户补款'
const first = vm.submitHoldAction()
const duplicate = vm.submitHoldAction()
await duplicate
expect(vm.holdSaving).toBe(true)
expect(holdPlatformInbound).toHaveBeenCalledOnce()
resolveCommand({
inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4,
canHold: false, canRelease: true, actionBlockReason: null, history: [],
})
await first
expect(vm.holdSaving).toBe(false)
})
it('keeps the dialog and reason when a stale-version submission fails', async () => {
vi.mocked(holdPlatformInbound).mockRejectedValueOnce(Object.assign(new Error('扣货状态已变化,请刷新后重试'), { code: 'A0409' }))
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as {
holdReason: string
holdDialogOpen: boolean
openHoldDialog(action: 'HOLD'): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('HOLD')
vm.holdReason = '等待客户补款'
await vm.submitHoldAction()
expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款' })
expect(vm.holdDialogOpen).toBe(true)
expect(vm.holdReason).toBe('等待客户补款')
expect(getPlatformUnifiedOrder).toHaveBeenCalledOnce()
expect(listPlatformUnifiedOrders).not.toHaveBeenCalled()
})
it('does not submit the command when evidence upload fails and preserves the form', async () => {
vi.mocked(uploadPlatformInboundHoldEvidence).mockRejectedValueOnce(new Error('文件服务不可用'))
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' })
const vm = wrapper.vm as unknown as {
holdReason: string
holdDialogOpen: boolean
holdEvidenceFile: File | null
holdEvidenceError: { message: string } | null
openHoldDialog(action: 'HOLD'): void
selectHoldEvidenceFile(upload: { raw: File }): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('HOLD')
vm.holdReason = '等待客户补款'
vm.selectHoldEvidenceFile({ raw: evidence })
await vm.submitHoldAction()
expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', evidence)
expect(holdPlatformInbound).not.toHaveBeenCalled()
expect(vm.holdDialogOpen).toBe(true)
expect(vm.holdReason).toBe('等待客户补款')
expect(vm.holdEvidenceFile).toBe(evidence)
expect(vm.holdEvidenceError?.message).toBe('关联文件上传失败,请稍后重试')
})
it('uploads optional evidence once and submits its long identifier', async () => {
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' })
const vm = wrapper.vm as unknown as {
holdReason: string
openHoldDialog(action: 'HOLD'): void
selectHoldEvidenceFile(upload: { raw: File }): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('HOLD')
vm.holdReason = '等待客户补款'
vm.selectHoldEvidenceFile({ raw: evidence })
await vm.submitHoldAction()
expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledOnce()
expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', {
expectedVersion: 3,
reason: '等待客户补款',
evidenceFileId: '9007199254740999',
})
})
it('rejects unsafe evidence extensions, empty files and files over 10MB', async () => {
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as {
holdEvidenceFile: File | null
holdEvidenceError: { code: string } | null
openHoldDialog(action: 'HOLD'): void
selectHoldEvidenceFile(upload: { raw: File }): void
}
vm.openHoldDialog('HOLD')
vm.selectHoldEvidenceFile({ raw: new File(['run'], '恶意程序.exe') })
expect(vm.holdEvidenceFile).toBeNull()
expect(vm.holdEvidenceError?.code).toBe('FILE_TYPE_INVALID')
vm.selectHoldEvidenceFile({ raw: new File([], '空文件.txt', { type: 'text/plain' }) })
expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID')
const oversized = new File(['x'], '过大文件.png', { type: 'image/png' })
Object.defineProperty(oversized, 'size', { value: 10 * 1024 * 1024 + 1 })
vm.selectHoldEvidenceFile({ raw: oversized })
expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID')
expect(uploadPlatformInboundHoldEvidence).not.toHaveBeenCalled()
})
it('downloads associated evidence once while a repeated click is pending', async () => {
let resolveDownload!: (value: Blob) => void
vi.mocked(downloadPlatformInboundHoldEvidence).mockImplementationOnce(() => new Promise((resolve) => { resolveDownload = resolve }))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:hold-evidence') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
const wrapper = mountDetail()
await flushPromises()
const vm = wrapper.vm as unknown as { downloadHoldEvidence(fileId: string): Promise<void>; holdDownloadingFileId: string }
const first = vm.downloadHoldEvidence('9007199254740999')
const duplicate = vm.downloadHoldEvidence('9007199254740999')
await duplicate
expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledOnce()
expect(vm.holdDownloadingFileId).toBe('9007199254740999')
resolveDownload(new Blob(['evidence'], { type: 'application/pdf' }))
await first
expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', '9007199254740999')
expect(click).toHaveBeenCalledOnce()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:hold-evidence')
expect(vm.holdDownloadingFileId).toBe('')
})
it('submits release with the current server version and refreshes detail and list', async () => {
vi.mocked(getPlatformInboundHold).mockResolvedValueOnce({
inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 8,
canHold: false, canRelease: true, actionBlockReason: null, history: [],
})
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
const vm = wrapper.vm as unknown as {
holdReason: string
openHoldDialog(action: 'RELEASE'): void
submitHoldAction(): Promise<void>
}
vm.openHoldDialog('RELEASE')
vm.holdReason = '货款已核对'
await vm.submitHoldAction()
expect(releasePlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 8, reason: '货款已核对' })
expect(getPlatformUnifiedOrder).toHaveBeenCalledTimes(2)
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
})
it('stays read-only without the independent hold permission', async () => {
canPermission.mockImplementation((...permissions: string[]) => permissions[0] !== 'platform:firstleg:inbound:hold')
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001')
expect(wrapper.text()).toContain('未扣货')
expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货']))
})
it('stays read-only and does not request hold detail before an inbound exists', async () => {
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({ ...detail, summary: { ...detail.summary, inboundId: null, inboundCode: null, holdFlag: null, currentHoldReason: null } } as never)
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
await flushPromises()
expect(getPlatformInboundHold).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('订单尚未生成入库单,扣货状态仅可查看')
expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货']))
})
})
@@ -79,6 +79,20 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('fbaCode: trimmed(activeFilters.fbaCode)')
})
it('keeps hold state in the other tab and consumes only frozen server capabilities', () => {
for (const marker of [
'summary.holdFlag', 'summary.currentHoldReason', 'getPlatformInboundHold',
'holdDetail.value?.canHold', 'holdDetail.value?.canRelease', "can('platform:firstleg:inbound:hold')",
'扣货历史', '下载凭证', '订单尚未生成入库单,扣货状态仅可查看',
'uploadPlatformInboundHoldEvidence', 'downloadPlatformInboundHoldEvidence',
'仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB',
]) expect(source).toContain(marker)
expect(source).toContain("await Promise.all([loadDetail(true), loadOrders()])")
expect(source).not.toContain('detail.summary.holdCargo')
expect(source).not.toContain('detail.summary.holdReason')
expect(source).toContain('evidenceFileId: holdUploadedEvidenceFileId.value')
})
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
expect(source).toContain('mainStatus: selectedStatus.value || undefined')
expect(source).toContain('<template #actions>')
@@ -94,6 +108,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('批量审核接口待接入')
expect(source).toContain('@selection-change="handleOrderSelectionChange"')
expect(source).toContain('batchApprovePlatformUnifiedOrders')
expect(source).toContain('getPlatformUnifiedOrderBatchApprovalPreview')
expect(source).toContain('中文品名')
expect(source).toContain('确认批量审核')
expect(source).toContain('批量审核通过')
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, FileUp, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
downloadPlatformProductImage,
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
listPlatformUnifiedOrders,
updatePlatformUnifiedOrderPlanning,
@@ -16,6 +17,8 @@ import {
type UnifiedOrderCoLoadGroupSummary,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
type UnifiedOrderBatchApprovalPreviewItem,
type UnifiedOrderBatchReviewItem,
} from '@/modules/unified-orders/api/unifiedOrders'
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
@@ -62,12 +65,18 @@ import {
createFeeActionIdempotencyKey,
getLatestInboundFee,
getInboundMeasurement,
getPlatformInboundHold,
downloadPlatformInboundHoldEvidence,
holdPlatformInbound,
releasePlatformInbound,
uploadPlatformInboundHoldEvidence,
saveInboundMeasurementDraft,
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
sendInboundFee,
type InboundMeasurementDetail,
type InboundHoldDetail,
} from '@/modules/inbounds'
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
@@ -144,6 +153,12 @@ const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const batchApprovalSaving = ref(false)
const batchApprovalLoading = ref(false)
const batchApprovalOpen = ref(false)
const batchApprovalError = ref<ApiErrorDetails | null>(null)
const batchApprovalPreviewRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalSelectedRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalTableRef = ref<{ toggleRowSelection: (row: UnifiedOrderBatchApprovalPreviewItem, selected?: boolean) => void }>()
const exportSubmitting = ref(false)
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
@@ -162,6 +177,19 @@ const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const noteOrder = ref<UnifiedOrderResource | null>(null)
const holdDetail = ref<InboundHoldDetail | null>(null)
const holdLoading = ref(false)
const holdError = ref<ApiErrorDetails | null>(null)
const holdLoadedKey = ref('')
const holdDialogOpen = ref(false)
const holdSaving = ref(false)
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
const holdReason = ref('')
const holdEvidenceFile = ref<File | null>(null)
const holdEvidenceUploadKey = ref(0)
const holdUploadedEvidenceFileId = ref('')
const holdEvidenceError = ref<ApiErrorDetails | null>(null)
const holdDownloadingFileId = ref('')
const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
@@ -224,6 +252,7 @@ let detailAssociationRequestSerial = 0
let measurementRequestSerial = 0
let measurementPreviewRequestSerial = 0
let subsequentFulfillmentRequestSerial = 0
let holdRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
@@ -242,6 +271,18 @@ function normalizedIdentifier(value: unknown) {
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
}
const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))
const currentHoldFlag = computed(() => holdDetail.value ? holdDetail.value.holdFlag : detail.value?.summary.holdFlag ?? null)
const currentHoldReason = computed(() => holdDetail.value ? holdDetail.value.currentHoldReason : detail.value?.summary.currentHoldReason ?? null)
const canHoldInbound = computed(() => Boolean(
normalizedInboundId.value
&& can('platform:firstleg:inbound:hold')
&& holdDetail.value?.canHold,
))
const canReleaseInbound = computed(() => Boolean(
normalizedInboundId.value
&& can('platform:firstleg:inbound:hold')
&& holdDetail.value?.canRelease,
))
const detailTabs = [
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
@@ -828,14 +869,22 @@ function releaseCargoImagePreviews() {
async function loadCargoImagePreviews(resource: UnifiedOrderDetailResource, serial: number) {
const tenantId = String(resource.summary.tenantId || '')
if (!tenantId) return
await Promise.all((resource.order?.cargoes ?? []).map(async (cargo) => {
const fileId = cargo.productImageFileId
if (fileId === null || fileId === undefined || fileId === '') return
const cargoesByFileId = new Map<string, typeof resource.order.cargoes>()
for (const cargo of resource.order?.cargoes ?? []) {
const fileId = cargo.productImageFileId === null || cargo.productImageFileId === undefined
? ''
: String(cargo.productImageFileId)
if (!fileId) continue
const cargoes = cargoesByFileId.get(fileId) ?? []
cargoes.push(cargo)
cargoesByFileId.set(fileId, cargoes)
}
await Promise.all([...cargoesByFileId.entries()].map(async ([fileId, cargoes]) => {
try {
const blob = await downloadPlatformProductImage(tenantId, String(fileId))
const blob = await downloadPlatformProductImage(tenantId, fileId)
if (serial !== detailRequestSerial || detailRouteKey.value !== `${tenantId}:${orderId.value}`) return
const url = URL.createObjectURL(blob)
cargo.imageUrl = url
cargoes.forEach((cargo) => { cargo.imageUrl = url })
cargoImagePreviewUrls.add(url)
} catch {
// Historical files may have been removed; keep the remaining cargo fields visible.
@@ -1192,10 +1241,40 @@ async function loadMeasurementDetails() {
}
}
async function loadHoldDetail(force = false) {
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
const key = inboundId && tenantId ? `${tenantId}:${inboundId}` : ''
if (!key || !can('platform:firstleg:inbound:list')) {
holdDetail.value = null
holdError.value = null
holdLoadedKey.value = ''
return
}
if (!force && (holdLoading.value || holdLoadedKey.value === key)) return
const serial = ++holdRequestSerial
holdLoading.value = true
holdError.value = null
try {
const result = await getPlatformInboundHold(tenantId, inboundId)
if (serial !== holdRequestSerial) return
holdDetail.value = result
holdLoadedKey.value = key
} catch (error) {
if (serial !== holdRequestSerial) return
holdDetail.value = null
holdLoadedKey.value = ''
holdError.value = getApiErrorDetails(error, '扣货状态与历史加载失败')
} finally {
if (serial === holdRequestSerial) holdLoading.value = false
}
}
function selectDetailTab(key: string) {
activeDetailTab.value = key
if (key === 'measurement') void loadMeasurementDetails()
if (key === 'fulfillment') void loadSubsequentFulfillment()
if (key === 'other') void loadHoldDetail()
}
const hasLoadingAssociation = computed(() => Boolean(loadingAssociation.value
@@ -1304,6 +1383,120 @@ async function saveNote() {
}
}
function openHoldDialog(action: 'HOLD' | 'RELEASE') {
const allowed = action === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
if (!allowed || !holdDetail.value) return
holdAction.value = action
holdReason.value = ''
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
holdEvidenceUploadKey.value += 1
holdDialogOpen.value = true
}
const holdEvidenceMaxBytes = 10 * 1024 * 1024
const holdEvidenceExtensions = new Set(['txt', 'pdf', 'jpg', 'jpeg', 'png'])
function selectHoldEvidenceFile(upload: UploadFile) {
const file = upload.raw || null
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
if (!file) return
const extension = file.name.split('.').pop()?.toLowerCase() || ''
if (!holdEvidenceExtensions.has(extension)) {
holdEvidenceError.value = { code: 'FILE_TYPE_INVALID', message: '关联文件仅支持 TXT、PDF、JPG、PNG' }
holdEvidenceUploadKey.value += 1
return
}
if (file.size <= 0 || file.size > holdEvidenceMaxBytes) {
holdEvidenceError.value = { code: 'FILE_SIZE_INVALID', message: '关联文件不能为空且不能超过 10MB' }
holdEvidenceUploadKey.value += 1
return
}
holdEvidenceFile.value = file
}
function removeHoldEvidenceFile() {
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
}
async function submitHoldAction() {
if (holdSaving.value) return
const allowed = holdAction.value === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
if (!allowed) return
const reason = holdReason.value.trim()
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!holdDetail.value || !inboundId || !tenantId) return
if (!reason) {
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
return
}
if (reason.length > 1000) {
ElMessage.warning('原因不能超过 1000 个字符')
return
}
holdSaving.value = true
try {
if (holdEvidenceFile.value && !holdUploadedEvidenceFileId.value) {
try {
holdEvidenceError.value = null
const uploaded = await uploadPlatformInboundHoldEvidence(tenantId, inboundId, holdEvidenceFile.value)
holdUploadedEvidenceFileId.value = uploaded.fileId
} catch (error) {
holdEvidenceError.value = getApiErrorDetails(error, '关联文件上传失败,请稍后重试')
ElMessage.error(holdEvidenceError.value.message)
return
}
}
const command = {
expectedVersion: holdDetail.value.version,
reason,
...(holdUploadedEvidenceFileId.value ? { evidenceFileId: holdUploadedEvidenceFileId.value } : {}),
}
holdDetail.value = holdAction.value === 'HOLD'
? await holdPlatformInbound(tenantId, inboundId, command)
: await releasePlatformInbound(tenantId, inboundId, command)
holdDialogOpen.value = false
await Promise.all([loadDetail(true), loadOrders()])
ElMessage.success(holdAction.value === 'HOLD' ? '扣货成功' : '解除扣货成功')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, holdAction.value === 'HOLD' ? '扣货失败' : '解除扣货失败').message)
} finally {
holdSaving.value = false
}
}
async function downloadHoldEvidence(fileId: string) {
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!fileId || !inboundId || !tenantId || holdDownloadingFileId.value || !can('platform:firstleg:inbound:list')) return
holdDownloadingFileId.value = fileId
try {
const blob = await downloadPlatformInboundHoldEvidence(tenantId, inboundId, fileId)
const extension = blob.type === 'application/pdf' ? '.pdf'
: blob.type === 'image/png' ? '.png'
: blob.type === 'image/jpeg' ? '.jpg'
: blob.type === 'text/plain' ? '.txt' : ''
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `扣货凭证${extension}`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '扣货凭证下载失败,请稍后重试').message)
} finally {
holdDownloadingFileId.value = ''
}
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
@@ -1625,27 +1818,62 @@ async function cancelCoLoadGroup() {
}
}
async function batchApproveOrders() {
function batchReviewItem(order: UnifiedOrderResource): UnifiedOrderBatchReviewItem {
return { orderId: String(order.orderId), orderVersion: Number(order.orderVersion || 0) }
}
function handleBatchApprovalSelection(rows: UnifiedOrderBatchApprovalPreviewItem[]) {
batchApprovalSelectedRows.value = rows
}
async function openBatchApproval() {
const selected = [...selectedApprovalOrders.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
if (!selected.length || batchApprovalLoading.value || batchApprovalSaving.value) return
batchApprovalOpen.value = true
batchApprovalLoading.value = true
batchApprovalError.value = null
batchApprovalPreviewRows.value = []
batchApprovalSelectedRows.value = []
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?审核后订单将进入待测量。`,
'批量审核订单',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
const groups = new Map<string, UnifiedOrderResource[]>()
selected.forEach((order) => {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
const previews = await Promise.all([...groups].map(([tenantId, orders]) => (
getPlatformUnifiedOrderBatchApprovalPreview(tenantId, orders.map(batchReviewItem))
)))
batchApprovalPreviewRows.value = previews.flatMap((preview) => preview.items)
await nextTick()
batchApprovalPreviewRows.value.forEach((row) => {
if (row.canApprove) batchApprovalTableRef.value?.toggleRowSelection(row, true)
})
} catch (error) {
batchApprovalError.value = getApiErrorDetails(error, '批量审核预览加载失败,请刷新订单后重试')
} finally {
batchApprovalLoading.value = false
}
}
async function batchApproveOrders() {
const selected = [...batchApprovalSelectedRows.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
try {
const groups = new Map<string, UnifiedOrderBatchApprovalPreviewItem[]>()
selected.forEach((order) => {
const tenantId = String(order.customerId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
let successCount = 0
let failureCount = 0
const failures: string[] = []
for (const [tenantId, orders] of groups) {
try {
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => String(order.orderId)))
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => ({
orderId: order.orderId,
orderVersion: order.orderVersion,
})))
successCount += result.successCount
failureCount += result.failureCount
failures.push(...result.failedOrders.map((failure) => failure.message))
@@ -1654,13 +1882,12 @@ async function batchApproveOrders() {
failures.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
batchApprovalOpen.value = false
await loadOrders()
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reason ? `。${reason}` : ''}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message)
} finally {
batchApprovalSaving.value = false
}
@@ -1831,6 +2058,11 @@ async function loadDetail(force = false) {
measurementDimensionConfig.value = null
measurementDimensionConfigLoadedKey.value = ''
measurementPreviewOpen.value = false
holdRequestSerial += 1
holdDetail.value = null
holdError.value = null
holdLoadedKey.value = ''
holdLoading.value = false
releaseMeasurementPreview()
releaseCargoImagePreviews()
detailLoading.value = true
@@ -1840,6 +2072,7 @@ async function loadDetail(force = false) {
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
detail.value = response
await loadCargoImagePreviews(response, serial)
if (activeDetailTab.value === 'other') await loadHoldDetail()
if (requestedDetailTab.value === 'measurement') {
activeDetailTab.value = 'measurement'
await loadMeasurementDetails()
@@ -1885,6 +2118,7 @@ onMounted(() => {
onBeforeUnmount(() => {
detailAssociationRequestSerial += 1
measurementRequestSerial += 1
holdRequestSerial += 1
releaseMeasurementPreview()
releaseCargoImagePreviews()
})
@@ -1964,8 +2198,6 @@ onBeforeUnmount(() => {
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
@@ -2054,6 +2286,7 @@ onBeforeUnmount(() => {
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:destination-type="detail.summary.destinationType"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
@@ -2204,9 +2437,28 @@ onBeforeUnmount(() => {
</template>
<template v-else>
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
<section class="hold-section" aria-label="订单扣货控制">
<div class="panel-title"><div><h2>扣货控制</h2><p>扣货状态独立于快递、箱子计费和内部备注维护</p></div><ShieldAlert :size="19" /></div>
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,扣货状态仅可查看" :image-size="56" />
<template v-else>
<div class="hold-summary">
<dl><div><dt>当前状态</dt><dd><el-tag :type="currentHoldFlag === true ? 'danger' : currentHoldFlag === false ? 'success' : 'info'" effect="plain">{{ currentHoldFlag === true ? '已扣货' : currentHoldFlag === false ? '未扣货' : '状态未知' }}</el-tag></dd></div><div><dt>当前原因</dt><dd>{{ currentHoldReason || '—' }}</dd></div></dl>
<div class="hold-actions"><el-button v-if="canHoldInbound" type="danger" :loading="holdSaving" @click="openHoldDialog('HOLD')"><ShieldAlert :size="15" />扣货</el-button><el-button v-if="canReleaseInbound" type="primary" plain :loading="holdSaving" @click="openHoldDialog('RELEASE')"><Undo2 :size="15" />解除扣货</el-button></div>
</div>
<el-alert v-if="!can('platform:firstleg:inbound:list')" title="当前账号只能查看订单返回的扣货状态,无法读取扣货历史" type="info" :closable="false" show-icon />
<LoadErrorState v-else-if="holdError" title="扣货状态与历史加载失败" :message="holdError.message" :loading="holdLoading" @retry="loadHoldDetail(true)"><template #details><ApiErrorMeta :details="holdError" /></template></LoadErrorState>
<template v-else>
<p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p>
<h3 class="hold-history-title">扣货历史</h3>
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="150"><template #default="{ row }"><el-button v-if="row.evidenceFileId" link type="primary" :loading="holdDownloadingFileId === row.evidenceFileId" @click="downloadHoldEvidence(row.evidenceFileId)"><Download :size="14" />下载凭证</el-button><span v-else>—</span></template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
</template>
</template>
</section>
<section class="other-notes-section" aria-label="内部备注">
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见,独立保存</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
</section>
</template>
</section>
</template>
@@ -2256,7 +2508,7 @@ onBeforeUnmount(() => {
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-actions>
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalLoading || batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="openBatchApproval"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
<el-button type="primary" plain :loading="coLoadSaving" :disabled="loading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</div>
@@ -2346,6 +2598,22 @@ onBeforeUnmount(() => {
</template>
</ListPageLayout>
<SmartLoadingDialog v-model="smartLoadingOpen" />
<AppDialog v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
<LoadErrorState v-if="batchApprovalError" compact title="批量审核预览加载失败" :message="batchApprovalError.message" :loading="batchApprovalLoading" @retry="openBatchApproval"><template #details><ApiErrorMeta :details="batchApprovalError" /></template></LoadErrorState>
<template v-else>
<div class="batch-approval-summary"><span>预览 {{ batchApprovalPreviewRows.length }} 票</span><strong>已勾选 {{ batchApprovalSelectedRows.length }} 票</strong></div>
<el-table ref="batchApprovalTableRef" class="batch-approval-table" :data="batchApprovalPreviewRows" row-key="orderId" border stripe max-height="520" empty-text="暂无可审核订单" @selection-change="handleBatchApprovalSelection">
<el-table-column type="selection" width="48" fixed="left" :selectable="(row: UnifiedOrderBatchApprovalPreviewItem) => row.canApprove" />
<el-table-column label="入仓码" min-width="160" show-overflow-tooltip><template #default="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template></el-table-column>
<el-table-column label="客户" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="中文品名" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ row.chineseGoodsNames.length ? row.chineseGoodsNames.join('、') : '—' }}</template></el-table-column>
<el-table-column label="渠道" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.channelName || row.channelCode || '—' }}</template></el-table-column>
<el-table-column label="目的仓" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template></el-table-column>
<el-table-column label="校验结果" min-width="180" show-overflow-tooltip><template #default="{ row }"><el-tag :type="row.canApprove ? 'success' : 'danger'" effect="light">{{ row.canApprove ? '可审核' : row.blockReason || '不可审核' }}</el-tag></template></el-table-column>
</el-table>
</template>
</AppDialog>
<AppDialog v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.inboundCode || '待生成入仓码' }}</span></div>
@@ -2382,6 +2650,11 @@ onBeforeUnmount(() => {
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
</AppDialog>
<AppDialog v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
</AppDialog>
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
</AppDialog>
@@ -2462,6 +2735,9 @@ onBeforeUnmount(() => {
.subsequent-fulfillment { min-height: 72px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 10px; font-size: 15px; }
.fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; overflow: hidden; }
.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 12px 0 8px; color: var(--cc-muted); font-size: 13px; white-space: nowrap; }
.batch-approval-summary strong { color: var(--cc-text); }
.batch-approval-table :deep(.cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fulfillment-facts > div { min-width: 0; padding: 9px 10px; border-right: 1px solid var(--cc-border); background: #fff; }
.fulfillment-facts > div.wide { grid-column: span 2; }
.fulfillment-facts dt { color: var(--cc-muted); font-size: 12px; }
@@ -2572,6 +2848,20 @@ onBeforeUnmount(() => {
.weight-evidence-card dt { color: var(--cc-muted); font-size: 11px; }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 12px; font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
.hold-section { padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.other-notes-section { padding-top: 18px; }
.hold-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.hold-summary dl { display: grid; min-width: 0; flex: 1; grid-template-columns: 150px minmax(280px, 1fr); gap: 18px; margin: 0; }
.hold-summary dl > div { min-width: 0; }
.hold-summary dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.hold-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.hold-actions :deep(.el-button) { margin-left: 0; }
.hold-block-reason { margin: 12px 0 0; padding: 9px 12px; border: 1px solid var(--el-color-warning-light-5); border-radius: 4px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 12px; }
.hold-history-title { margin: 16px 0 8px; color: #344054; font-size: 14px; }
.hold-history-table { min-height: 90px; overflow-x: auto; }
.hold-command-form { margin-top: 16px; }
.hold-evidence-hint { margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
+32 -2
View File
@@ -12,8 +12,8 @@ const TableStub = defineComponent({
})
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean },
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)"><slot /></div>',
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean, className: String },
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)" :data-class-name="className"><slot /></div>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
@@ -152,6 +152,36 @@ describe('DataTable column preferences', () => {
expect(wrapper.get('.data-table__pagination').text()).toContain('共 0 条')
})
it('keeps ordinary cells on one line with overflow tooltips and exempts system columns', () => {
const wrapper = shallowMount(DataTable, {
props: {
data: [],
columnSettingsMode: 'hide',
columns: [
{ key: 'actions', label: '操作', fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户' },
{ key: 'remark', label: '备注', singleLine: false },
],
},
global: {
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub, ElSelect: true, ElOption: true },
directives: { loading: {} },
},
})
const columns = wrapper.findAll('.column-stub')
expect(columns[0].attributes()).toMatchObject({
'data-overflow-tooltip': 'undefined',
'data-class-name': 'data-table__system-cell',
})
expect(columns[1].attributes()).toMatchObject({
'data-overflow-tooltip': 'true',
'data-class-name': 'data-table__single-line-cell',
})
expect(columns[2].attributes('data-overflow-tooltip')).toBe('undefined')
expect(columns[2].attributes('data-class-name')).toBe('')
})
it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, {
props: {
+26 -1
View File
@@ -10,6 +10,9 @@ export interface DataTableColumn {
hideable?: boolean
movable?: boolean
defaultVisible?: boolean
singleLine?: boolean
showOverflowTooltip?: boolean
className?: string
[attribute: string]: unknown
}
@@ -241,7 +244,7 @@ watch([page, pageCount], ([value, count]) => {
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, ...attrs }: DataTableColumn) {
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, singleLine, ...attrs }: DataTableColumn) {
const hasDataProperty = attrs.prop !== undefined || attrs.property !== undefined
const isSpecialColumn = slotName || ['selection', 'expand', 'index'].includes(String(attrs.type || ''))
if (hasDataProperty || isSpecialColumn || key === undefined || key === null || key === '') return attrs
@@ -257,6 +260,22 @@ function columnAttrs({ key, slotName, headerSlotName, hideable, movable, default
}
}
function singleLineColumn(column: DataTableColumn) {
return column.singleLine !== false && !systemColumn(column)
}
function columnOverflowTooltip(column: DataTableColumn) {
return singleLineColumn(column) ? column.showOverflowTooltip ?? true : column.showOverflowTooltip
}
function columnClassName(column: DataTableColumn) {
return [
column.className,
systemColumn(column) ? 'data-table__system-cell' : '',
singleLineColumn(column) ? 'data-table__single-line-cell' : '',
].filter(Boolean).join(' ')
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
@@ -309,6 +328,8 @@ function setPage(value: number) {
v-for="(column, index) in visibleColumns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
:class-name="columnClassName(column)"
:show-overflow-tooltip="columnOverflowTooltip(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
@@ -379,6 +400,10 @@ function setPage(value: number) {
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; font-size: var(--cc-list-page-button-font-size); }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
.data-table :deep(.data-table__system-cell .el-button + .el-button),
.data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
@media (min-width: 1920px) {
.data-table__toolbar { min-height: 64px; gap: 12px; padding: 10px 14px; }