Merge origin/release/test and preserve local frontend changes

This commit is contained in:
2026-10-20 11:48:38 +08:00
265 changed files with 14131 additions and 5723 deletions
+6 -5
View File
@@ -21,11 +21,11 @@
|---|---|---|
| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 |
| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 |
| 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` |
| 排车资源、草稿容器与实际容量 | `components/DispatchResourceFields.vue`、`components/SmartDispatchDialog.vue`、`dispatchContracts.ts` | 车辆/司机分别选择已有档案或临时外配,容量取真实档案或本趟填写的托位、千克及立方米,不按车型名称推算。草稿容器容量不代替车辆实测或装载校验;取证、审核前补齐规则见当前排车正式契约 |
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
| 排车查询、整车草稿、审核及派送安排 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
@@ -34,18 +34,19 @@
## 履约硬边界
- 智能排车来源支持 `RELABEL`(待出库换标单),切换后不传已选入库计划 ID,按客户已确认、卡车派送且未排车的换标库存查询。提交换标号从候选 `orderRefs` 读取,不能把展示的原入库单号 `orderNo` 当换标号;沿用来源库存、托盘与箱唛,不生成新入库单。
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车与实际发车分别确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库经头程排车转出库;换标只有平台卡车进入预排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务;普通库存仍按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送安排在装车完成后、实际发车前进行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 验证
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 排车转派送弹框打开时并行读取一次`getDispatchExternalFreight`和`getDispatchFeeCoverage`;不逐行查费用。`DeliveryFeeCoveragePanel`按实际各票原费用明确勾选派送/装车/买约,提交来源ID/版本/hash与货物摘要,不按金额推断覆盖。`fullyCoveredServices`只控制输入展示,服务端重新核验;只有全部货物已包含的项目才不提交新增应收,部分覆盖仍需填剩余项目。外接报价只读并提交externalFreightHash。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
- 派送安排弹框通过`getDispatchArrangement`读取整车分组、实际货物、外接报价及原费用依据,`DispatchArrangeFields.vue`按真实客户目的地组提交;不逐行查费用,不按金额推断覆盖,不把原费用再次记入新费用行。移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
@@ -1,161 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch, getBatchDispatchCandidates } from './dispatches'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
it('reads an immutable batch and preserves its exact per-source allocations on grouped submission', async () => {
const allocation = { planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9', customerId: '9007199254740999', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12 }
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { batchKey: allocation.batchKey, version: 4, candidates: [], blockReasons: [] } })
await getBatchDispatchCandidates(allocation.batchKey)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/batches/FBA_APPOINTMENT%3A9/dispatch-candidates')
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [] })
const request = { draftToken: 'draft-1', groups: [{ draftContainerId: 'container-1', planPalletIds: [allocation.planPalletId], returnReceiptIds: [], batchAllocations: [allocation] }] }
await preArrangeDraftDispatch(request)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', request)
})
it('submits all explicit draft groups once and preserves container and source identities', async () => {
const payload = { draftToken: 'original-draft', groups: [
{ draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
{ draftContainerId: '190000000000000100', planPalletIds: ['12'], returnReceiptIds: ['22'] },
] }
const result = payload.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: { id: String(index), dispatchNo: `DS-${index}` } }))
vi.mocked(http.post).mockResolvedValue({ code: '200', data: result })
expect(await preArrangeDraftDispatch(payload)).toEqual(result)
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload)
})
it('propagates a batch rejection without falling back to a flat or repeated command', async () => {
vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '草稿来源已变化', data: null })
await expect(preArrangeDraftDispatch({ draftToken: 'original-draft', groups: [
{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: [] },
] })).rejects.toThrow('草稿来源已变化')
expect(http.post).toHaveBeenCalledTimes(1)
})
it('locks and submits actual return receipts without pretending they are plan pallets', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: null })
await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', {
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1',
})
await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', {
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return',
})
await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return',
})
})
it('reads external freight from the scoped dispatch without a supplied customer', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
})
it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
const result = await getDispatchFeeCoverage('DP/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
})
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the platform dispatch page without customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 },
})
await listDispatches({
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
params: {
page: 2, size: 50, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
},
})
})
it('loads a platform dispatch detail by encoded dispatch number', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
await getDispatch('PC/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('requests the generated BOL as a PDF blob', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['%PDF-test']))
await getDispatchBolPdf('PC/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
})
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
})
it('loads platform candidates without selected inbound plans', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
await listDispatchCandidates({ sourceStage: 'RECEIVED' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
params: { sourceStage: 'RECEIVED' },
})
})
it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await lockDispatchDraftPallet('99', 'draft-1')
await unlockDispatchDraftPallet('99', 'draft-1')
await releaseDispatchDraft('draft-1')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100' })
await cancelDispatch('PC/1')
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
expect(http.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await listDispatchDrafts()
await createDispatchDraftContainer('draft/1', 30)
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
})
import * as api from './dispatches'
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
import type { ArrangeDelivery, CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
const body = { recordVersion: 0, idempotencyKey: 'original-key', reason: '核对原因' }
const draft = () => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [], sources: [candidate()], expiresAt: '' })
const file = () => ({ fileId: '9007199254740993', fileName: '证据.png', contentType: 'image/png', sizeBytes: 3 })
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockImplementation(async url => ({ data: url.endsWith('/items') ? { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [] } : url.includes('/evidence/') ? file() : url.endsWith('/loading') ? loadingPreview() : url.includes('/commands/') ? command('EDIT') : detail() }))
vi.mocked(http.post).mockImplementation(async url => ({ data: url.endsWith('/pre-arrange') ? { draftToken: 'server-token', vehicles: [], replayed: false } : url.includes('/draft') ? draft() : url.includes('/evidence/') ? file() : command('EDIT') }))
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
})
describe('native whole-vehicle dispatch API', () => {
it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
await api.createDispatch(create); await api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }); await api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] })
expect(vi.mocked(http.post).mock.calls[0]![1]).toBe(create)
expect(vi.mocked(http.post).mock.calls[1]![1]).toMatchObject({ batchAllocations: [{ recordVersion }] })
expect(vi.mocked(http.post).mock.calls[2]![1]).toMatchObject({ groups: [{ dispatch: { batchAllocations: [{ recordVersion }] } }] })
})
it.each([undefined, null, -1, '', 'bad', 9007199254740992])('rejects missing or invalid nested batch version %s before any write or candidate handoff', async value => {
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: value } as unknown as DispatchBatchAllocation
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
await expect(Promise.resolve().then(() => api.createDispatch(create))).rejects.toThrow('重新读取候选')
await expect(Promise.resolve().then(() => api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }))).rejects.toThrow('重新读取候选')
await expect(Promise.resolve().then(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }))).rejects.toThrow('重新读取候选')
expect(http.post).not.toHaveBeenCalled()
vi.mocked(http.get).mockResolvedValueOnce({ data: { batchKey: allocation.batchKey, version: 5, candidates: [{ allocations: [allocation] }], blockReasons: [] } })
await expect(api.getBatchDispatchCandidates(allocation.batchKey)).rejects.toThrow('重新读取候选')
})
it('rejects inconsistent original versions of the same batch across conversion groups', () => {
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: '9007199254740993' }
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] }
expect(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'A', dispatch: create }, { draftContainerId: 'B', dispatch: { ...create, batchAllocations: [{ ...allocation, recordVersion: '9007199254740994' }] } }] })).toThrow('同一预约批次的版本不一致')
expect(http.post).not.toHaveBeenCalled()
})
it('uses actual item, route and evidence endpoints with native multipart and authenticated blob content', async () => {
await api.getDispatchItems('PC/1'); await api.getDispatchRoute('PC/1'); await api.getDispatchEvidence('PC/1', 'DRIVER', '9007199254740993')
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/items', '/platform/v1/last-leg/dispatches/PC%2F1/route', '/platform/v1/last-leg/dispatches/PC%2F1/evidence/DRIVER/9007199254740993'])
const file = new File(['png'], '证据.png', { type: 'image/png' }); await api.uploadDispatchEvidence('PC/1', 'VEHICLE', file)
const upload = vi.mocked(http.post).mock.calls[0]!
expect(upload[0]).toBe('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE'); expect(upload[1]).toBeInstanceOf(FormData); expect((upload[1] as FormData).get('file')).toBe(file)
await api.getDispatchEvidenceContent('PC/1', 'VEHICLE', '301'); expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE/301/content', { responseType: 'blob' })
})
it('reorders only the supplied existing destination keys with explicit native version zero', async () => {
await expect(api.reorderDispatchRoute('PC/1', { recordVersion: null as unknown as number, idempotencyKey: 'key', destinationKeys: [], reason: '路线调整' })).rejects.toThrow('版本')
expect(http.put).not.toHaveBeenCalled()
const request = { recordVersion: 0, idempotencyKey: 'key', destinationKeys: ['CODE:ONT8', 'PRIVATE:actual'], reason: '现场卸货顺序调整' }
await api.reorderDispatchRoute('PC/1', request); expect(http.put).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/PC%2F1/route', request)
})
it('queries five-stage rows with server review sections and preserves statistics', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ data: page() })
const query = { page: 2, size: 20, stage: 'BEFORE_LOADING' as const, beforeLoadingSection: 'DISPATCH_REVIEW' as const, reviewStatus: 'PENDING' as const, status: 'ACTIVE' as const }
const result = await api.listDispatches(query)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', { params: query }); expect(result.statistics.stageCounts.DELIVERING).toBe(1)
})
it('reads the four-object detail and loading without customer scope, encoding business numbers', async () => {
await api.getDispatch('PC/1'); await api.getDispatchLoading('PC/1'); await api.getDispatchCommand('PC/1', 'key/1')
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
})
it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
vi.mocked(http.get).mockResolvedValueOnce({ data })
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
})
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
})
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'depart', 'arrange', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
const invoke = (value: unknown) => {
const request = { ...body, recordVersion: value as Version }
switch (action) {
case 'cancel': return api.cancelDispatch('PC/1', request)
case 'remove': return api.removeDispatchItem('PC/1', '11', request)
case 'review': return api.reviewDispatch('PC/1', request as unknown as ReviewCommand)
case 'loading-review': return api.reviewDispatchLoading('PC/1', request as unknown as ReviewCommand)
case 'return': return api.returnDispatchToReview('PC/1', request)
case 'loading': return api.completeDispatchLoading('PC/1', request as unknown as LoadingComplete)
case 'depart': return api.departDispatch('PC/1', { ...request, remark: null })
case 'arrange': return api.transferDispatchToDelivery('PC/1', request as unknown as ArrangeDelivery)
case 'add': return api.addDispatchCargo('PC/1', { ...request, cargo: [], batchAllocations: [] })
case 'submit': return api.submitDispatchReview('PC/1', { ...request, remark: null })
case 'draft-container': return api.createDispatchDraftContainer({ ...request, draftToken: 'draft', capacity: 30 })
case 'draft-select': return api.lockDispatchDraftPallet(request as unknown as DraftSelection)
case 'draft-release': return api.releaseDispatchDraft({ ...request, draftToken: 'draft' })
case 'draft-convert': return api.convertDispatchDraft({ ...request, draftToken: 'draft', groups: [] })
default: return api.saveDispatchDraftSnapshot({ ...request, draftToken: 'draft', planIds: [], containers: [] })
}
}
for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then<unknown>(() => invoke(invalid))).rejects.toThrow('版本')
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
})
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
})
it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
await api.cancelDispatch('PC/1', body); await api.removeDispatchItem('PC/1', 'item/1', body)
expect(vi.mocked(http.post).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/cancel', '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove']); expect(http.delete).not.toHaveBeenCalled()
})
it('preserves complete grouped arrangement evidence and returns an immutable command envelope', async () => {
const outbound = (customerId: string) => ({ customerId, customerName: `客户${customerId}`, outboundNo: `OUT-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 2, palletCount: 1 })
const delivery = (customerId: string) => ({ ...outbound(customerId), groupKey: customerId, deliveryNo: `DEL-${customerId}`, destination: candidate().destination })
const data = { ...command('ARRANGE_DELIVERY'), result: { dispatchNo: 'PC/1', recordVersion: '9007199254740993', arrangementKey: 'arrange', cargoHash: 'cargo', arrangedAt: '2026-10-19T10:00:00Z', outbounds: [outbound('A'), outbound('B')], deliveries: [delivery('A'), delivery('B')] } }
vi.mocked(http.post).mockResolvedValueOnce({ data })
const request: ArrangeDelivery = { ...body, recordVersion: '9007199254740993', cargoHash: 'cargo', loadingCompletionHash: 'loaded', groups: [] }
expect(await api.transferDispatchToDelivery('PC/1', request)).toEqual(data); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', request)
})
it('keeps draft version, server token and source evidence and converts explicit groups atomically', async () => {
const source = candidate().selection
await api.lockDispatchDraftPallet({ ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', { ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
await api.convertDispatchDraft({ ...body, draftToken: 'server-token', groups: [] }); expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', { ...body, draftToken: 'server-token', groups: [] })
})
it('deletes draft containers with the body version and never invents successful response data', async () => {
vi.mocked(http.delete).mockRejectedValueOnce(new Error('server failure'))
await expect(api.deleteDispatchDraftContainer('draft/1', 'container/1', body as ReasonCommand)).rejects.toThrow('server failure')
expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/container%2F1', { data: body })
})
it('reads BOL as a PDF blob', async () => { await api.getDispatchBolPdf('PC/1'); expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' }) })
})
+56 -338
View File
@@ -1,346 +1,64 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import { requireVersion, requireBatchVersions, type AddCargo, type ArrangeDelivery, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeArrangementPreview, normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type ArrangementPreviewWire, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface DispatchQuery {
page: number
size: number
dispatchNo?: string
vehiclePlate?: string
status?: string
fbaAppointmentNo?: string
stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
}
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
export interface DispatchActions {
canEdit: boolean
editBlockReason: string | null
canCancel: boolean
cancelBlockReason: string | null
canRemovePallet: boolean
removePalletBlockReason: string | null
canTransfer: boolean
transferBlockReason: string | null
outboundNo: string | null
outboundStatus: string | null
}
export interface DispatchPalletCandidateDto {
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
returnReceiptId?: string | null
returnReceiptNo?: string | null
planId?: string | null
planPalletId?: string | null
warehousePalletId?: string | null
orderId: string
customerId?: string | null
customerName?: string | null
planNo?: string | null
orderType?: string | null
volumeWeightKg?: number | null
grossWeightKg?: number | null
containerNo?: string | null
appointmentAt?: string | null
expectedLoadingAt?: string | null
overseasWarehouseId?: string | null
overseasWarehouseCode?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
inboundCode?: string | null
palletNo?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
volumeCbm?: number | null
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL' | null
cartonMarks?: string | null
relabelCartonMarks?: string | null
destinationType?: 'FBA' | 'PRIVATE' | null
deliveryAddress?: string | null
postalCode?: string | null
contactName?: string | null
contactPhone?: string | null
}
export interface DispatchCandidateQuery {
planIds?: string
orderNo?: string
fbaWarehouseCode?: string
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL'
draftToken?: string
}
export interface DispatchDraftContainerDto { id: string; capacity: number; palletIds: string[] }
export interface DispatchDraftDto { draftToken: string; containers?: DispatchDraftContainerDto[] }
export interface PreArrangeDispatchPayload {
batchAllocations?: DispatchBatchAllocation[]
planPalletIds: string[]
returnReceiptIds?: string[]
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
draftToken?: string
relabelNo?: string
}
export interface PreArrangeDispatchDraftGroup {
batchAllocations?: DispatchBatchAllocation[]
draftContainerId: string
planPalletIds: string[]
returnReceiptIds: string[]
}
export interface PreArrangeDispatchDraftPayload {
draftToken: string
groups: PreArrangeDispatchDraftGroup[]
}
export interface PreArrangeDispatchDraftResult {
draftContainerId: string
dispatch: DispatchSummaryDto
}
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number }
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
export async function getBatchDispatchCandidates(batchKey: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<BatchDispatchCandidates>, ApiEnvelope<BatchDispatchCandidates>>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(batchKey)}/dispatch-candidates`))
}
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
export interface DispatchSummaryDto {
actions?: DispatchActions
appointmentBatches: DispatchBatchReference[]
workflow?: DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
orderNos?: string | null
route?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
status?: string | null
itemCount?: number | null
palletCount?: number | null
packageCount?: number | null
pickedPackageCount?: number | null
shippedPackageCount?: number | null
remainingPackageCount?: number | null
partialCompletionReason?: string | null
createdAt?: string | null
items?: DispatchItemDto[]
bolLines?: DispatchBolLineDto[]
}
export interface DispatchBolLineDto {
containerNo?: string | null
palletNo?: string | null
fbaWarehouseCode?: string | null
destinationWarehouseName?: string | null
warehouseNo?: string | null
fbaShipmentId?: string | null
poReferenceNo?: string | null
packageCount: number
}
export interface UpdateDispatchPayload {
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
}
export interface DispatchItemDto {
id: string
planPalletId: string
warehousePalletId?: string | null
palletNo?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
orderRefs?: string | null
inboundCode?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
fbaWarehouseCode?: string | null
packageCount?: number | null
grossWeightKg?: number | null
volumeWeightKg?: number | null
volumeCbm?: number | null
sourceStage?: string | null
}
const base = '/platform/v1/last-leg/dispatches'
const path = (dispatchNo: string) => `${base}/${encodeURIComponent(dispatchNo)}`
async function get<T>(url: string) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url)) }
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
const postCommand = (url: string, body: object) => post<CommandResultWire>(url, body).then(normalizeDispatchCommand)
const postDraft = (url: string, body: object) => post<DispatchDraftWire>(url, body).then(normalizeDispatchDraft)
function versioned<T extends VersionCommand>(body: T): T { requireVersion(body.recordVersion); return body }
export async function listDispatches(query: DispatchQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>, ApiEnvelope<OverseasPagePayload<DispatchSummaryDto>>>(
'/platform/v1/last-leg/dispatches',
{ params: query },
)
return normalizeOverseasPage(unwrapApiResponse(response))
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
}
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
export async function uploadDispatchEvidence(no: string, kind: DispatchEvidenceKind, file: File) { const form = new FormData(); form.append('file', file); return post<DispatchEvidenceFileWire>(evidencePath(no, kind), form).then(normalizeEvidenceFile) }
export const getDispatchEvidence = (no: string, kind: DispatchEvidenceKind, id: string) => get<DispatchEvidenceFileWire>(evidencePath(no, kind, id)).then(normalizeEvidenceFile)
export const getDispatchEvidenceContent = (no: string, kind: DispatchEvidenceKind, id: string) => http.get<Blob, Blob>(`${evidencePath(no, kind, id)}/content`, { responseType: 'blob' })
export const getDispatchLoading = (dispatchNo: string) => get<LoadingPreviewWire>(`${path(dispatchNo)}/loading`).then(normalizeLoadingPreview)
export const getDispatchCommand = (dispatchNo: string, key: string) => get<CommandResultWire>(`${path(dispatchNo)}/commands/${encodeURIComponent(key)}`).then(normalizeDispatchCommand)
export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
versioned(body)
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(path(dispatchNo), body)))
}
export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
export const removeDispatchItem = (no: string, itemId: string, body: ReasonCommand) => postCommand(`${path(no)}/items/${encodeURIComponent(itemId)}/remove`, versioned(body))
export const cancelDispatch = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/cancel`, versioned(body))
export const submitDispatchReview = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/submit-review`, versioned(body))
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
export const departDispatch = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/depart`, versioned(body))
export const getDispatchArrangement = (no: string) => get<ArrangementPreviewWire>(`${path(no)}/delivery-arrangement`).then(normalizeArrangementPreview)
export const transferDispatchToDelivery = (no: string, body: ArrangeDelivery) => postCommand(`${path(no)}/transfer-delivery`, versioned(body))
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
export async function getDispatch(dispatchNo: string) {
const response = await http.get<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
export async function listDispatchCandidates(query: CandidateQuery) {
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/candidates`, { params: query, paramsSerializer: { indexes: null } })))
}
export const listDispatchDrafts = () => get<DispatchDraftWire[]>(`${base}/drafts`).then(values => values.map(normalizeDispatchDraft))
export const createDispatchDraft = (body: { idempotencyKey: string; warehouseId: string }) => postDraft(`${base}/drafts`, body)
export const createDispatchDraftContainer = (body: VersionCommand & { draftToken: string; capacity: number }) => postDraft(`${base}/drafts/containers`, versioned(body))
export const saveDispatchDraftSnapshot = (body: VersionCommand & { draftToken: string; planIds: string[]; containers: DraftContainer[] }) => postDraft(`${base}/drafts/snapshot`, versioned(body))
export const lockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/lock`, versioned(body))
export const unlockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/unlock`, versioned(body))
export const releaseDispatchDraft = (body: VersionCommand & { draftToken: string }) => postDraft(`${base}/draft-locks/release`, versioned(body))
export async function deleteDispatchDraftContainer(token: string, id: string, body: ReasonCommand) {
return normalizeDispatchDraft(unwrapApiResponse(await http.delete<ApiEnvelope<DispatchDraftWire>, ApiEnvelope<DispatchDraftWire>>(`${base}/drafts/${encodeURIComponent(token)}/containers/${encodeURIComponent(id)}`, { data: versioned(body) })))
}
export const convertDispatchDraft = (body: ConvertDraft) => { versioned(body); requireBatchVersions(body.groups.flatMap(group => group.dispatch.batchAllocations)); return post<ConvertedDraftWire>(`${base}/drafts/pre-arrange`, body).then(normalizeConvertedDraft) }
export async function getDispatchBolPdf(dispatchNo: string) {
return http.get<Blob, Blob>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`,
{ responseType: 'blob' },
)
}
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
const response = await http.put<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
)
return unwrapApiResponse(response)
}
export async function cancelDispatch(dispatchNo: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function removeDispatchItem(dispatchNo: string, itemId: string) {
const response = await http.delete<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`,
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
const response = await http.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
'/platform/v1/last-leg/dispatches/candidates',
{ params: query },
)
return unwrapApiResponse(response)
}
export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
const response = await http.post<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
'/platform/v1/last-leg/dispatches/pre-arrange',
payload,
)
return unwrapApiResponse(response)
}
export async function preArrangeDraftDispatch(payload: PreArrangeDispatchDraftPayload) {
const response = await http.post<ApiEnvelope<PreArrangeDispatchDraftResult[]>, ApiEnvelope<PreArrangeDispatchDraftResult[]>>(
'/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload,
)
return unwrapApiResponse(response)
}
function draftSource(selection: string) {
return selection.startsWith('RETURN:')
? { sourceType: 'RETURN_RECEIPT', returnReceiptId: selection.slice(7) }
: { planPalletId: selection }
}
export async function lockDispatchDraftPallet(selection: string, draftToken: string, draftContainerId?: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
)
return unwrapApiResponse(response)
}
export async function listDispatchDrafts() {
const response = await http.get<ApiEnvelope<DispatchDraftDto[]>, ApiEnvelope<DispatchDraftDto[]>>('/platform/v1/last-leg/dispatches/drafts')
return unwrapApiResponse(response)
}
export async function createDispatchDraftContainer(draftToken: string, capacity: number) {
const response = await http.post<ApiEnvelope<DispatchDraftContainerDto>, ApiEnvelope<DispatchDraftContainerDto>>('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken, capacity })
return unwrapApiResponse(response)
}
export async function deleteDispatchDraftContainer(draftToken: string, containerId: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/dispatches/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
return unwrapApiResponse(response)
}
export async function unlockDispatchDraftPallet(selection: string, draftToken: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken },
)
return unwrapApiResponse(response)
}
export async function releaseDispatchDraft(draftToken: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken },
)
return unwrapApiResponse(response)
}
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
export interface TransferDeliveryPayload {
feeCoverageCargoHash?: string
feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
externalFreightHash?: string
idempotencyKey: string
plannedDeliveryAt: string
deliveryMethod: string
waybillNo?: string
carrierName?: string
pickupMethod: string
loadingType: string
vehicleType?: string
vehicleSize?: string
driverName?: string
driverPhone?: string
vehiclePlate?: string
appointmentAccount?: string
sellerName?: string
remark?: string
fees: Array<{ feeType: string; feeName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'; amount: number
supplierId?: string; supplierName?: string; supplierCategory?: string; settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
const response = await http.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, payload,
)
return unwrapApiResponse(response)
}
export interface ExternalFreightPreview {
snapshotHash: string
requiresDeliveryFee: boolean
quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
}
export async function getDispatchExternalFreight(dispatchNo: string): Promise<ExternalFreightPreview> {
const response = await http.get<ApiEnvelope<ExternalFreightPreview>, ApiEnvelope<ExternalFreightPreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
)
return unwrapApiResponse(response)
}
export async function getDispatchFeeCoverage(dispatchNo: string): Promise<import('../feeCoverageTypes').FeeCoveragePreview> {
const response = await http.get<ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>, ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
)
const data = unwrapApiResponse(response)
return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
}
// Existing appointment-domain provider; its consumer handoff remains with the coordinator.
export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
export const getBatchDispatchCandidates = (key: string) => get<BatchDispatchCandidates>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(key)}/dispatch-candidates`).then(value => { requireBatchVersions(value.candidates.flatMap(row => row.allocations)); return value })
@@ -3,6 +3,7 @@ import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -105,32 +106,33 @@ async function save() {
</script>
<template>
<AppDialog class="admin-density-dialog" v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<div class="delivery-documents">
<div class="document-identifiers"><label>派送单号<el-input :model-value="deliveryNo" readonly /></label><label>BOL号<el-input v-model="bolNo" :readonly="!canEdit" :disabled="busy || conflict" placeholder="未填写" /></label></div>
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<AppDialog class="admin-density-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<template v-if="snapshot">
<div class="document-summary"><span>{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</span><strong>{{ uploadedCount }}/3 已上传</strong></div>
<div class="document-groups">
<section v-for="group in draft" :key="group.category" class="document-group" :data-category="group.category">
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="document-empty">尚未上传文件</p>
<div v-for="file in group.files" :key="file.fileId" class="document-file">
<div class="document-file-info"><button type="button" class="document-file-name" :title="file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">{{ file.fileName }}</button><small>{{ file.uploadedAt?.replace('T', ' ') || '—' }}{{ isSaved(group.category, file.fileId) ? '' : ' · 待保存' }}</small></div>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
</div>
</section>
</div>
<section class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" placeholder="未填写" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in draft" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody>
<tr v-for="file in group.files" :key="file.fileId" class="document-file">
<td><div class="density-actions"><el-button link :aria-label="'下载 ' + file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">下载</el-button><template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template></div></td>
<td><LineClampText :content="file.fileName" /></td><td>{{ file.uploadedAt?.replace('T', ' ') || '—' }}</td><td>{{ isSaved(group.category, file.fileId) ? '已保存' : '待保存' }}</td>
</tr>
</tbody></table>
</section>
</template>
</div>
</AppDialog>
</template>
<style scoped>
.delivery-documents { display:grid; gap:var(--admin-space-5); padding:2px var(--admin-space-2) var(--admin-space-1) 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:var(--admin-space-5); }.document-identifiers label { display:grid; gap:var(--admin-space-3); font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-5) var(--admin-space-5); background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:var(--admin-radius); }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:var(--admin-radius); overflow:hidden; }.document-group { padding:var(--admin-space-5) var(--admin-space-5); }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:var(--admin-space-4); }.document-group header > span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:var(--admin-space-5); padding-top:var(--admin-space-4); }.document-file-info { display:grid; min-width:0; flex:1; gap:var(--admin-space-1); }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:var(--admin-space-4) 0 0; }
.document-group header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.document-group header h3 { margin: 0; }
.document-group header > :last-child { margin-left: auto; }
.document-files { table-layout: fixed; }
.document-file-actions { width: 220px; }
.document-file-time { width: 170px; }
.document-file-state { width: 90px; }
</style>
@@ -0,0 +1,35 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
import { destination } from '../dispatchTestFixtures'
const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) as CargoLine
function values() {
const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
return { preview, group }
}
it('requires explicit per-service coverage and preserves uncovered cargo instead of treating a partial fee as full coverage', async () => {
const { preview, group } = values()
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const covered = (wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }).covered
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(group.feeCoverage).toEqual([{ financeFeeId: 'fee-1', sourceVersion: 2, sourceHash: 'fee-hash', services: ['DELIVERY_FEE'] }])
expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
preview.feeSources[0]!.sourceKeys.push('B'); expect(covered(0, preview, 'DELIVERY_FEE')).toBe(true)
expect(covered(0, preview, 'LOADING_FEE')).toBe(false)
preview.feeSources[0]!.customerId = 'C2'; expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false); wrapper.unmount()
})
it('matches external quotes to the original customer and inbound source and shows only applicable additional fees', async () => {
const { preview, group } = values(); preview.cargo = [cargo('A')]
preview.externalQuotes = [{ customerId: 'C2', groupKey: 'group-1', bookingNo: 'BOOK-1', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'external-source' }]
const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }
expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(false)
expect(wrapper.findAll('.el-input-number')).toHaveLength(1)
preview.externalQuotes[0]!.customerId = 'C1'; expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(true)
group.loadingType = 'FLOOR'; group.appointmentAccount = 'BUY_APPOINTMENT'; await wrapper.setProps({ modelValue: [{ ...group }] })
expect(wrapper.findAll('.el-input-number')).toHaveLength(2); expect(wrapper.text()).toContain('卖家姓名'); wrapper.unmount()
})
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { ArrangeGroup, PreviewGroup } from '../dispatchContracts'
const groups = defineModel<ArrangeGroup[]>({ required: true })
defineProps<{ preview: PreviewGroup[]; disabled: boolean }>()
const serviceLabels: Record<string, string> = { DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' }
function setCoverage(index: number, fee: PreviewGroup['feeSources'][number], service: string, checked: unknown) {
const group = groups.value[index]; if (!group) return
let selection = group.feeCoverage.find(row => row.financeFeeId === fee.financeFeeId)
if (!selection && checked === true) { selection = { financeFeeId: fee.financeFeeId, sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: [] }; group.feeCoverage.push(selection) }
if (!selection) return
selection.services = checked === true ? [...new Set([...selection.services, service])] : selection.services.filter(value => value !== service)
group.feeCoverage = group.feeCoverage.filter(row => row.services.length)
}
function covered(index: number, preview: PreviewGroup, service: string) {
if (!preview.cargo.length) return false
const selections = groups.value[index]?.feeCoverage || []
return preview.cargo.every(cargo => service === 'DELIVERY_FEE' && preview.externalQuotes.some(quote => quote.customerId === cargo.customerId && quote.inboundCode === cargo.inboundCode)
|| preview.feeSources.some(fee => fee.customerId === cargo.customerId && fee.sourceKeys.includes(cargo.sourceKey) && selections.some(selection => selection.financeFeeId === fee.financeFeeId && selection.services.includes(service))))
}
function required(index: number, preview: PreviewGroup, service: string) {
const group = groups.value[index]
return preview.requiredServices.includes(service)
|| service === 'LOADING_FEE' && ['FLOOR', 'MIXED'].includes(group?.loadingType || '')
|| service === 'APPOINTMENT_FEE' && group?.appointmentAccount === 'BUY_APPOINTMENT'
}
defineExpose({ covered })
</script>
<template>
<section v-for="(previewGroup, index) in preview" :key="previewGroup.groupKey" class="arrange-group density-dialog-sections">
<template v-if="groups[index]">
<section class="density-dialog-section" data-tone="source" :aria-label="'派送分组依据 ' + (index + 1)"><h3 class="density-dialog-section__title">{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
<el-alert v-for="reason in previewGroup.blockReasons" :key="reason" :title="reason" type="warning" :closable="false" show-icon />
<el-table :data="previewGroup.cargo" border><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" show-overflow-tooltip /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
<template v-if="previewGroup.externalQuotes.length"><h4 class="density-dialog-section__title">外接约定运费</h4><table class="density-table"><thead><tr><th>外接预订号</th><th>入仓码</th><th>币种</th><th>金额</th></tr></thead><tbody><tr v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo"><td>{{ quote.bookingNo }}</td><td>{{ quote.inboundCode }}</td><td>{{ quote.currency }}</td><td>{{ quote.amount }}</td></tr></tbody></table></template>
<h4 class="density-dialog-section__title">原费用覆盖</h4><p v-if="!previewGroup.feeSources.length" class="density-dialog-note">暂无可引用原费用。</p>
<table v-else class="density-table"><thead><tr><th>覆盖服务</th><th>币种</th><th>金额</th><th>状态</th></tr></thead><tbody><tr v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><td><el-checkbox v-for="service in fee.services" :key="service" :disabled="disabled" :model-value="groups[index]!.feeCoverage.some(row => row.financeFeeId === fee.financeFeeId && row.services.includes(service))" @update:model-value="setCoverage(index, fee, service, $event)">已包含{{ serviceLabels[service] || '服务费用' }}</el-checkbox></td><td>{{ fee.currency }}</td><td>{{ fee.amount }}</td><td>{{ fee.status }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" :aria-label="'派送分组登记 ' + (index + 1)"><h3 class="density-dialog-section__title">派送信息</h3>
<table class="density-table density-table--editable arrange-editor-table"><tbody>
<tr><td><el-form-item label="预计派送时间" required><el-date-picker v-model="groups[index]!.plannedDeliveryAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择派送时间" /></el-form-item></td><td><el-form-item label="派送方式" required><el-select v-model="groups[index]!.deliveryMethod" :disabled="disabled" placeholder="请选择"><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="提货方式" required><el-select v-model="groups[index]!.pickupMethod" :disabled="disabled" placeholder="请选择"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item></td><td><el-form-item label="装车类型" required><el-select v-model="groups[index]!.loadingType" :disabled="disabled" placeholder="请选择"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item></td><td><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item></td></tr>
<tr><td><el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item></td><td><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item></td></tr>
<tr><td><el-form-item label="预约账号"><el-select v-model="groups[index]!.appointmentAccount" :disabled="disabled" clearable placeholder="请选择"><el-option label="自有账号" value="SELF" /><el-option label="买约" value="BUY_APPOINTMENT" /></el-select></el-form-item></td><td><el-form-item v-if="groups[index]!.appointmentAccount === 'BUY_APPOINTMENT'" label="卖家姓名" required><el-input v-model="groups[index]!.sellerName" :disabled="disabled" placeholder="请输入卖家姓名" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item></td></tr>
</tbody></table>
<h4 class="density-dialog-section__title">本组新增费用</h4><table class="density-table density-table--editable"><tbody><tr v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><td><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="(serviceLabels[fee.feeType] || fee.feeName) + '(未覆盖部分)'" required><div class="arrange-fee-controls"><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" controls-position="right" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></div></el-form-item><p v-else class="density-dialog-note">本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></td></tr></tbody></table>
</section>
</template>
</section>
</template>
<style scoped>
.arrange-editor-table { table-layout: fixed; }
.arrange-editor-table td:not([colspan]) { width: 50%; }
.arrange-fee-controls { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 120px; gap: var(--admin-space-3); }
.arrange-group h4 { margin-top: var(--admin-space-4); }
</style>
@@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import type { DispatchEvidenceFile } from '../dispatchContracts'
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
const get = vi.spyOn(http, 'get').mockResolvedValue(envelope('9007199254740993'))
const post = vi.spyOn(http, 'post').mockResolvedValue(envelope('9007199254740994'))
vi.mocked(api.getDispatchEvidence).mockImplementation(actual.getDispatchEvidence); vi.mocked(api.uploadDispatchEvidence).mockImplementation(actual.uploadDispatchEvidence)
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: ['9007199254740993'] } })
try {
await flushPromises(); expect(wrapper.text()).toContain('本车证据.png')
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['9007199254740994']]); expect(post).toHaveBeenCalledOnce()
} finally { wrapper.unmount(); get.mockRestore(); post.mockRestore() }
})
it('reads owned metadata without displaying IDs or downloading content until explicitly previewed, including read-only mode', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, readonly: true } }); await flushPromises()
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('本车证据.png'); expect(wrapper.text()).not.toContain('301'); expect(wrapper.find('input').exists()).toBe(false)
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); wrapper.unmount()
})
it('retains original references when metadata or replacement upload fails and preserves request diagnostics', async () => {
vi.mocked(api.getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('Forbidden', 'A0403', 403, undefined, 'request-1', 'trace-1'))
const failed = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(failed.emitted('update:modelValue')).toBeUndefined(); expect(failed.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-1', traceId: 'trace-1' }); failed.unmount()
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('unknown upload'))
await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' })); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('原附件引用保留'); wrapper.unmount()
})
it('replaces the selected reference only after a valid successful upload and rejects unsupported types locally', async () => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await selectFile(wrapper, new File(['exe'], 'file.exe', { type: 'application/octet-stream' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('10MiB以内')
await button(wrapper, '替换').trigger('click'); const file = new File(['png'], 'new.png', { type: 'image/png' }); await selectFile(wrapper, file)
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', file); expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); expect(wrapper.emitted('busy')?.slice(-2)).toEqual([[true], [false]]); wrapper.unmount()
})
it('ignores a late upload after switching dispatches instead of binding the file to the new vehicle', async () => {
let resolve!: (value: DispatchEvidenceFile) => void; vi.mocked(api.uploadDispatchEvidence).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); resolve(metadata('302')); await flushPromises(); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('does not turn business-error JSON into an image and retains diagnostic evidence', async () => {
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce({ type: 'application/json', size: 100, text: async () => JSON.stringify({ code: 'A0403', message: 'Forbidden', requestId: 'request-content', traceId: 'trace-content' }) } as Blob)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false); expect(wrapper.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-content', traceId: 'trace-content' }); wrapper.unmount()
})
it('ignores content arriving after preview close and cleans up object URLs on unmount', async () => {
let resolve!: (value: Blob) => void; vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await button(wrapper, '关闭预览').trigger('click'); resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled()
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
})
it('opens PDF in a user-requested window using owned blob content and never an anonymous URL', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
const popup = { location: { href: '' }, close: vi.fn() }; const open = vi.spyOn(window, 'open').mockReturnValueOnce(popup as unknown as Window)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(open).toHaveBeenCalledWith('', '_blank'); expect(popup.location.href).toBe('blob:authenticated'); expect(wrapper.find('img').exists()).toBe(false); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); open.mockRestore()
})
it('rejects mismatched file identity and a blocked PDF popup without fetching content', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('wrong-file'))
const mismatch = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(button(mismatch, '预览').attributes('disabled')).toBeDefined(); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); mismatch.unmount()
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
})
@@ -0,0 +1,93 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean }>()
const ids = defineModel<string[]>({ required: true })
const emit = defineEmits<{ busy: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const label = computed(() => props.kind === 'VEHICLE' ? '本趟车辆证据' : '本趟司机证照')
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
function valid(value: DispatchEvidenceFile, expected?: string) {
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
return { ...value, fileId: String(value.fileId) }
}
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...ids.value]
closePreview(); metadata.value = {}; error.value = null
if (!no) return
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
if (current !== generation) return
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
if (!file || props.readonly || props.disabled || uploading.value) return
error.value = null
if (!types.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError('请选择10MiB以内的PDF、JPG或PNG文件'), '附件不符合要求'); return }
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
uploading.value = true; emit('busy', true)
try {
const value = valid(await uploadDispatchEvidence(no, kind, file))
if (current !== generation) return
if (replaceId && !ids.value.includes(replaceId)) throw new DispatchInputError('原附件选择已变化,请重新核对')
ids.value = [...new Set(replaceId ? ids.value.map(id => id === replaceId ? value.fileId : id) : [...ids.value, value.fileId])]
} catch (cause) { if (current === generation) fail(cause, '上传结果未确认,原附件引用保留,请核对后重试') }
finally { uploading.value = false; emit('busy', false) }
}
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
async function preview(id: string) {
const file = metadata.value[id]; if (!file || previewLoading.value) return
closePreview(); const current = ++previewGeneration, scope = generation
error.value = null; previewLoading.value = true
if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } }
else { imageName.value = file.fileName; imageOpen.value = true }
try {
const blob = await getDispatchEvidenceContent(props.dispatchNo, props.kind, id)
if (current !== previewGeneration || scope !== generation) return
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
}
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
const url = URL.createObjectURL(blob); urls.add(url)
if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) }
else imageUrl.value = url
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}</h4>
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
<p v-else class="density-dialog-note">暂无本趟附件。</p>
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button><p class="density-dialog-note">PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
</AppDialog>
</section>
</template>
<style scoped>
.dispatch-evidence-table { table-layout: fixed; }
.dispatch-evidence__actions { width: 190px; }
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
.evidence-input { display: none; }
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
</style>
@@ -0,0 +1,28 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchResourceFields from './DispatchResourceFields.vue'
import { resources } from '../dispatchTestFixtures'
import { listDrivers, listVehicles } from '@/modules/overseas-master-data'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
})
it.each([false, true])('resets only the switched resource and keeps choices independent (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
})
it.each([false, true])('requires each directory permission before reading candidates (continuous=%s)', async continuous => {
can.mockReturnValue(false); const value = resources()
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, global: { plugins: [ElementPlus] } })
const state = wrapper.vm as unknown as { searchVehicles(): Promise<void>; searchDrivers(): Promise<void> }; await state.searchVehicles(); await state.searchDrivers()
expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
})
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue'
import { listDrivers, listVehicles, overseasMasterDataPermissions as resourcePermission, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Driver, Vehicle } from '../dispatchContracts'
const vehicle = defineModel<Vehicle>('vehicle', { required: true })
const driver = defineModel<Driver>('driver', { required: true })
defineProps<{ disabled: boolean; continuous?: boolean }>()
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
let vehicleSequence = 0, driverSequence = 0
async function searchVehicles(keyword = '') {
if (!access.can(resourcePermission.vehicleList)) { error.value = '缺少车辆档案读取权限'; return }
const sequence = ++vehicleSequence
try { const result = await listVehicles({ page: 1, size: 100, keyword, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (sequence === vehicleSequence) vehicles.value = result.records }
catch (cause) { if (sequence === vehicleSequence) error.value = cause instanceof Error ? cause.message : '车辆候选读取失败' }
}
async function searchDrivers(keyword = '') {
if (!access.can(resourcePermission.driverList)) { error.value = '缺少司机档案读取权限'; return }
const sequence = ++driverSequence
try { const result = await listDrivers({ page: 1, size: 100, keyword, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: driver.value.evidence?.expectedTaskEndAt || undefined }); if (sequence === driverSequence) drivers.value = result.records }
catch (cause) { if (sequence === driverSequence) error.value = cause instanceof Error ? cause.message : '司机候选读取失败' }
}
function changeVehicle() { vehicle.value = { mode: vehicle.value.mode, vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] } }
function changeDriver() { driver.value = { mode: driver.value.mode, driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
function selectVehicle(id: string) {
const selected = vehicles.value.find(row => row.id === id)
vehicle.value = { mode: 'MASTER', vehicleId: id, plate: selected?.plateNumber || null, carrierName: null, capacity: null, evidenceFileIds: [] }
}
function selectDriver(id: string) {
const selected = drivers.value.find(row => row.id === id), end = driver.value.evidence?.expectedTaskEndAt || null
driver.value = { mode: 'MASTER', driverId: id, name: selected?.details.name || null, phone: selected?.details.phone || null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: end } }
}
</script>
<template>
<template v-if="continuous">
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<table class="density-table density-table--editable dispatch-resource-table"><tbody>
<tr><td><el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item><el-form-item v-else label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item></td></tr>
<template v-if="vehicle.mode === 'EXTERNAL'">
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
</template>
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
<tr v-if="driver.evidence"><td v-if="driver.mode === 'EXTERNAL'"><el-form-item label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></td><td :colspan="driver.mode === 'MASTER' ? 2 : 1"><el-form-item label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item></td></tr>
</tbody></table>
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" class="density-dialog-note" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
</template>
<template v-else>
<p v-if="error" role="alert">{{ error }}</p>
<div class="resource-grid">
<el-form-item label="车辆来源"><el-select v-model="vehicle.mode" :disabled="disabled" @change="changeVehicle"><el-option label="车辆档案" value="MASTER" /><el-option label="临时外配车辆" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="vehicle.mode === 'MASTER'" label="车辆"><el-select v-model="vehicle.vehicleId" filterable remote :remote-method="searchVehicles" :disabled="disabled" placeholder="按车牌搜索" @focus="searchVehicles()" @change="selectVehicle"><el-option v-if="vehicle.vehicleId && !vehicles.some(row => row.id === vehicle.vehicleId)" :value="vehicle.vehicleId" :label="vehicle.plate || '已关联车辆(待核实)'" /><el-option v-for="item in vehicles" :key="item.id" :value="item.id" :label="item.plateNumber" /></el-select></el-form-item>
<template v-else>
<el-form-item label="车牌号"><el-input v-model="vehicle.plate" :disabled="disabled" placeholder="请输入本趟车牌号" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item>
<template v-if="vehicle.capacity"><el-form-item label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" /></el-form-item></template>
</template>
<el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item>
<el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item>
<template v-else><el-form-item label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item><el-form-item v-if="driver.evidence" label="驾驶证到期日"><el-date-picker v-model="driver.evidence.licenseExpiresOn" :disabled="disabled" type="date" value-format="YYYY-MM-DD" placeholder="请选择证照到期日" /></el-form-item></template>
<el-form-item v-if="driver.evidence" label="预计任务结束"><el-date-picker v-model="driver.evidence.expectedTaskEndAt" :disabled="disabled" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
</div>
<p v-if="vehicle.mode === 'EXTERNAL' || driver.mode === 'EXTERNAL'" role="status">临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。</p>
</template>
</template>
<style scoped>
.dispatch-resource-table { table-layout: fixed; }
.dispatch-resource-table td:not([colspan]) { width: 50%; }
.resource-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.resource-grid :deep(.el-select), .resource-grid :deep(.el-date-editor), .resource-grid :deep(.el-input-number) { width: 100%; }
</style>
@@ -0,0 +1,217 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/dispatches'
import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const gate = { allowed: true, blockCode: null, blockReason: null }
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } }
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
function arrangement(): ArrangementPreview {
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
const first = group('A', 'C1'), second = group('B', 'C2')
first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }]
second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }]
return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
URL.createObjectURL = vi.fn().mockReturnValue('blob:owned-dispatch'); URL.revokeObjectURL = vi.fn()
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, actions }))
vi.mocked(api.getDispatchCommand).mockRejectedValue(new ApiResponseError('not found', 'A0404', 404))
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.reviewDispatch).mockImplementation(async (_, body) => command('DISPATCH_REVIEW', body.idempotencyKey))
vi.mocked(api.reviewDispatchLoading).mockImplementation(async (_, body) => command('LOADING_REVIEW', body.idempotencyKey))
vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey))
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey))
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey))
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302'))
})
async function render(action: DispatchAction, operation?: 'ROUTE', files = false) {
const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301']
vi.mocked(api.getDispatch).mockResolvedValueOnce(initial)
const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
}
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
describe('dispatch workflow native layout', () => {
it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => {
const { dialog } = await render('EDIT')
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
await field(dialog, '车牌号').get('input').setValue('CA-NEW')
field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22)
await field(dialog, '司机姓名').get('input').setValue('新司机')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z')
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('维护备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
const { vehicleStatus: _status, ...vehicle } = resources().vehicle
expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' })
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('保留本次输入')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
expect(dialog.text()).toContain('workflow-request')
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
expect(dialog.props('title')).toBe('维护车辆信息 · PC/1')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
expect((dialog.get('textarea[placeholder="请输入本次备注(选填)"]').element as HTMLTextAreaElement).value).toBe('保留本次输入')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action)
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接')
dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE')
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
expect(rows).toHaveLength(2)
expect(rows[0]!.findAll('button')[0]!.attributes('disabled')).toBeDefined()
await rows[1]!.findAll('button').find(button => button.text() === '上移')!.trigger('click'); await flushPromises()
await dialog.get('textarea').setValue(' 顺序调整 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
})
it('removes the real item identity while keeping scanned cargo unavailable', async () => {
const { dialog } = await render('REMOVE_CARGO')
const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' })
expect(radios[1]!.props('disabled')).toBe(true)
radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ')
expect(dialog.text()).not.toContain('9223372036854775001')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' })
})
it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => {
const [first, second] = sharedPalletCandidates()
const { dialog } = await render('ADD_CARGO')
const source = dialog.get('details')
await source.get('summary').trigger('click'); await flushPromises()
expect(source.findAll('li')).toHaveLength(2)
await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] })
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
})
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
const { dialog } = await render('LOADING_COMPLETE')
const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' })
expect(numbers[1]!.props('modelValue')).toBeNull()
expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled()
numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' })
})
it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => {
const { dialog } = await render('ARRANGE_DELIVERY')
const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记'))
expect(sections).toHaveLength(2)
const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue')
await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises()
for (let index = 0; index < 2; index++) {
const section = sections[index]!
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
}
field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲')
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY')
field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises()
expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }])
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] }))
})
it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => {
let resolve!: (value: DispatchEvidenceFile) => void
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render('EDIT', undefined, true)
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
const image = files.findComponent(AppDialog)
expect(image.props('modelValue')).toBe(true)
expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch')
await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch')
const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
resolve(evidence('302')); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(false)
expect(files.props('modelValue')).toEqual(['301', '302'])
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: expect.objectContaining({ evidenceFileIds: ['301', '302'] }) }))
})
it('keeps server gates and write permissions independent from readable facts', async () => {
can.mockReturnValue(false)
const { dialog } = await render('EDIT')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateDispatch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,157 @@
import ElementPlus, { ElMessage } from 'element-plus'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import * as api from '../api/dispatches'
import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
interface State { submit(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
selection.selectCargo(first!, true)
first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' }
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化')
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
first!.selectionGroup.complete = true
selection.selectCargo(first!, true)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
wrapper.unmount()
})
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit()
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
} finally { wrapper.unmount() }
})
it('blocks cargo addition when the authoritative batch version is absent', async () => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('removes an unpicked authoritative item after real JSON and API normalization of pickedPackages string zero', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const source = candidate()
const data = { dispatchNo: 'PC/1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: '2', pickedPackages: '0', remove: { allowed: true, blockCode: null, blockReason: null } }] }
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data })))
vi.mocked(api.getDispatchItems).mockImplementation(actual.getDispatchItems)
const { wrapper, state } = await open('REMOVE_CARGO')
try {
(wrapper.vm as unknown as { itemId: string }).itemId = '9007199254740993'; state.form.reason = '本车计划调整'
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, request) => command('REMOVE_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit()
expect(api.removeDispatchItem).toHaveBeenCalledWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: '0', reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1)
} finally { wrapper.unmount(); get.mockRestore() }
})
async function open(action: DispatchAction, allowed = true) {
vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('keeps order allocation references out of actual measurements and submits native version zero only after real input', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
const { wrapper, state } = await open('LOADING_COMPLETE')
expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 }))
await state.submit()
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
value.pallets[0]!.evidence.push({ field: 'weightKg', actual: true, value: 92, sourceType: 'MEASURED', sourceBusinessNo: 'PALLET-1', observedAt: null })
value.confirmedLoad = { manifestHash: 'manifest', operatorId: 'operator', confirmedAt: '2026-10-19T00:00:00Z', pallets: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('requires a rejection reason and retains the full original command after an unknown review result', async () => {
const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED'
await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
})
it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
const { wrapper, state } = await open('ARRANGE_DELIVERY')
const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' }))
await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77
vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise<CommandResult> => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit()
const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1]
expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' })
expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })])
expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
const { wrapper, state } = await open('DEPART')
let resolve!: (value: DispatchDetail) => void
vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
})
it('removes only the authoritative unpicked item using its item ID as path identity and retains that original target on recovery', async () => {
const source = candidate()
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: { allowed: true, blockCode: null, blockReason: null } }] })
const { wrapper, state } = await open('REMOVE_CARGO'); const target = wrapper.vm as unknown as { itemId: string }; target.itemId = '9007199254740993'
await state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled()
state.form.reason = '本车计划调整'; vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown')); await state.submit()
target.itemId = 'another-item'; state.form.reason = 'later changed'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
const toast = vi.spyOn(ElMessage, 'success')
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: '实际起点', destinationKey: null }, { index: 1, name: 'ONT8', address: '实际地址A', destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接卸货点', address: '实际地址B', destinationKey: 'PRIVATE:actual' }] })
const { wrapper, state } = await open('EDIT'); await wrapper.setProps({ operation: 'ROUTE' }); await flushPromises()
const routeState = wrapper.vm as unknown as { moveStop(index: number, offset: number): void }; routeState.moveStop(1, -1); state.form.reason = '按本车现场顺序卸货'
vi.mocked(api.reorderDispatchRoute).mockRejectedValueOnce(new Error('unknown route')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
})
it('refuses already picked item removal and a route that drops an authoritative destination', async () => {
const source = candidate()
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: 'item-picked', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 1, remove: { allowed: false, blockCode: 'PICKED', blockReason: '本来源已经真实扫码' } }] })
const removed = await open('REMOVE_CARGO'); (removed.wrapper.vm as unknown as { itemId: string }).itemId = 'item-picked'; removed.state.form.reason = '调整'; await removed.state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled(); expect(removed.wrapper.text()).toContain('已经真实扫码'); removed.wrapper.unmount()
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
})
@@ -0,0 +1,273 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
import DispatchArrangeFields from './DispatchArrangeFields.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
const emit = defineEmits<{ saved: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const { pending, busy } = recovery
const currentAction = computed(() => pending.value?.action || props.action)
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
items.value = null; itemId.value = ''; route.value = null; stops.value = []
try {
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
const value = await api.getDispatch(no)
if (sequence !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
detail.value = value
const oldVehicle = value.vehicleInfo.vehicle
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
if (currentOperation.value === 'ROUTE') {
const data = await api.getDispatchRoute(no)
if (sequence !== generation) return
if (data.dispatchNo !== no || !data.stops.length || data.stops[0]?.index !== 0) throw new DispatchInputError('本车真实起点及路线未完整返回')
route.value = data; stops.value = data.stops.slice(1).map(stop => ({ ...stop }))
}
if (action === 'REMOVE_CARGO') {
const data = await api.getDispatchItems(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('移货清单与原排车单不一致')
items.value = data
}
if (action === 'LOADING_COMPLETE') {
const data = await api.getDispatchLoading(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('实载预览与排车单不一致')
preview.value = data
measurements.value = data.pallets.map(row => {
const confirmed = data.confirmedLoad?.manifestHash === data.manifestHash ? data.confirmedLoad.pallets.find(item => item.warehousePalletId === row.warehousePalletId) : null
const actual = (field: string) => row.evidence.some(item => item.field === field && item.actual === true)
return { warehousePalletId: row.warehousePalletId, palletNo: row.palletNo, palletSpaces: row.palletSpaces, deliveryIndex: row.deliveryIndex,
weightKg: confirmed?.weightKg ?? (actual('weightKg') ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (actual('volumeCbm') ? row.volumeCbm : null) }
})
}
if (action === 'ARRANGE_DELIVERY') {
const data = await api.getDispatchArrangement(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('派送预览与排车单不一致')
arrangement.value = data
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
}
if (action === 'ADD_CARGO') await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) loading.value = false }
}
async function loadCandidates(sequence = generation) {
// Detail has no warehouse field: resolve the authoritative list row by this business number.
const page = await api.listDispatches({ page: 1, size: 1, dispatchNo: currentNo.value, status: 'ACTIVE' })
const row = page.records.find(item => item.dispatchNo === currentNo.value)
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
const result = await api.listDispatchCandidates({ warehouseId: row.warehouseId, orderNo: candidateKeyword.value || undefined })
if (sequence !== generation) return
if (result.complete !== true) throw new DispatchInputError('候选来源未完整返回,请缩小范围重新查询')
if (result.candidates.some(source => source.warehouseId !== row.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion)) throw new DispatchInputError('货物起运仓或来源版本不完整,请重新读取')
candidates.value = result.candidates
}
async function searchCargo() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
function requirePlanPallet(row: Candidate): string {
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
return row.selection.planPalletId
}
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
function selectCargo(row: Candidate, checked: unknown) {
try {
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
const group = requireSelectionGroup(row)
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
}
function payload(): object {
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
if (currentOperation.value === 'ROUTE') {
const data = route.value, keys = stops.value.map(stop => stop.destinationKey)
if (!data || data.edit.allowed !== true || !form.reason.trim() || !keys.length || keys.some(key => !key) || new Set(keys).size !== keys.length || keys.length !== data.stops.length - 1 || data.stops.slice(1).some(stop => !keys.includes(stop.destinationKey))) throw new DispatchInputError('请完整保留本车既有卸货点并填写调整原因')
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
}
switch (currentAction.value) {
case 'REMOVE_CARGO': {
const row = items.value?.items.find(value => value.dispatchItemId === itemId.value)
if (!row || row.remove.allowed !== true || row.pickedPackages !== 0 || !form.reason.trim()) throw new DispatchInputError(row?.remove.blockReason || '请选择允许移除的未扫码货物并填写原因')
return { recordVersion: requireVersion(items.value!.recordVersion), idempotencyKey: common.idempotencyKey, reason: form.reason.trim() } satisfies ReasonCommand
}
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'ADD_CARGO': {
const chosen = selected.value.map(value => {
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
return row
})
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
}
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
}
case 'ARRANGE_DELIVERY': {
const data = arrangement.value
if (!data || data.arrange.allowed !== true || !data.groups.length || data.groups.some(row => row.blockReasons.length)) throw new DispatchInputError(data?.arrange.blockReason || '本车派送资料未就绪')
const prepared = groups.value.map((group, index) => {
if (!group.plannedDeliveryAt || !group.deliveryMethod || !group.pickupMethod || !group.loadingType) throw new DispatchInputError('请完整填写每个客户及目的仓的派送信息')
if (group.appointmentAccount === 'BUY_APPOINTMENT' && !group.sellerName?.trim()) throw new DispatchInputError('买约时卖家姓名必填')
const required = [...new Set([...(data.groups[index]?.requiredServices || []), ...(['FLOOR', 'MIXED'].includes(group.loadingType) ? ['LOADING_FEE'] : []), ...(group.appointmentAccount === 'BUY_APPOINTMENT' ? ['APPOINTMENT_FEE'] : [])])]
const fees = group.fees.filter(fee => required.includes(fee.feeType) && !arrangeFields.value?.covered(index, data.groups[index]!, fee.feeType))
if (fees.some(fee => fee.amount == null || !Number.isFinite(fee.amount) || fee.amount <= 0)) throw new DispatchInputError('请填写本组未覆盖服务的正数新增费用')
return { ...group, fees }
})
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, cargoHash: data.cargoHash, loadingCompletionHash: data.loadingCompletionHash, groups: prepared } satisfies ArrangeDelivery
}
default: return { ...common, remark: form.remark || null }
}
}
async function send(command: PendingDispatchCommand): Promise<CommandResult> {
const no = command.dispatchNo, body = command.body
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
switch (command.action) {
case 'REMOVE_CARGO': if (!command.itemId) throw new DispatchInputError('原移货请求缺少真实明细,请保留现场核对'); return api.removeDispatchItem(no, command.itemId, body as unknown as ReasonCommand)
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
case 'ADD_CARGO': return api.addDispatchCargo(no, body as unknown as AddCargo)
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
case 'CANCEL': return api.cancelDispatch(no, body as unknown as ReasonCommand)
case 'LOADING_COMPLETE': return api.completeDispatchLoading(no, body as unknown as LoadingComplete)
case 'ARRANGE_DELIVERY': return api.transferDispatchToDelivery(no, body as unknown as ArrangeDelivery)
case 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
case 'DEPART': return api.departDispatch(no, body as unknown as VersionCommand & { remark: string | null })
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
async function submit() {
if (busy.value || loading.value || uploadingEvidence.value) return
error.value = null
try {
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
if (!pending.value) {
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
const routeCommand = pending.value?.operation === 'ROUTE'
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
if (!result) return
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '操作结果尚未确认,请保留原请求核对') }
}
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" title="原操作结果尚待核对,当前使用原版本和原请求内容。" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending?.rejected" :disabled="busy || loading || uploadingEvidence" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
</section>
<template v-else-if="currentAction === 'EDIT'">
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
</section>
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
</template>
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
</section>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
</template>
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</template>
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.workflow-editor-table { table-layout: fixed; }
.workflow-editor-table td { width: 50%; }
.workflow-order-actions { width: 160px; white-space: nowrap; }
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.workflow-cargo-query > .el-input { max-width: 320px; }
.workflow-source-group > summary { cursor: pointer; }
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
</style>
@@ -1,8 +1,27 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import type { AppointmentForm } from '../appointmentForm'
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean }>()
defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string | null; disabled?: boolean; continuous?: boolean }>()
</script>
<template>
<div v-if="continuous" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="预约来源信息"><h3 class="density-dialog-section__title">预约信息</h3><table class="density-table"><tbody><tr><th scope="row">系统单号</th><td>{{ appointmentNo || '保存后生成' }}</td><th scope="row">目的仓时区</th><td>{{ timezone || '待核实' }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="预约安排登记"><h3 class="density-dialog-section__title">预约安排</h3>
<el-form class="density-form" label-position="left" label-width="140px" :disabled="disabled"><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item></td><td><el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'">
<tr><td colspan="2"><el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item></td></tr>
<tr><td><el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item></td><td><el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item></td></tr>
</template>
<tr><td colspan="2"><el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item></td></tr>
</tbody></table>
<details><summary>补充计划时段与夏令时信息</summary><table class="density-table density-table--editable appointment-editor-table"><tbody>
<tr><td><el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item></td><td><el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
<template v-if="model.appointmentStatus === 'SUCCESS'"><tr><td colspan="2"><span class="density-dialog-note">当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</span><InlineHelp title="夏令时填写说明">仅重叠时刻需填写对方确认的时差;修改确认时间后须重新核对时差。</InlineHelp></td></tr><tr><td><el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item></td><td><el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item></td></tr></template>
</tbody></table></details></el-form>
</section>
</div>
<template v-else>
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
@@ -24,8 +43,13 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
</div>
</details>
</el-form>
</template>
</template>
<style scoped>
.appointment-editor-table { table-layout: fixed; }
.appointment-editor-table td:not([colspan]) { width: 50%; }
details:has(.appointment-editor-table) { margin-top: var(--admin-space-4); }
details:has(.appointment-editor-table) > summary { cursor: pointer; margin-bottom: var(--admin-space-3); }
.appointment-fields, .appointment-fields__extra-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; }
.appointment-fields__extra { grid-column:1 / -1; }
@@ -59,8 +59,8 @@ onBeforeUnmount(() => { ++generation })
<p v-if="loading">资料加载中…</p>
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
<el-table-column prop="kind" label="文件类型" min-width="150" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="downloadFile(String(row.key))">下载</el-button></template></el-table-column>
<el-table-column prop="kind" label="文件类型" min-width="150" />
</el-table>
</section>
</template>
@@ -0,0 +1,204 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Vehicle } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' }
const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } }
function draft(): DispatchDraft {
const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' })
return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
await field(dialog, '车牌号').get('input').setValue(plate)
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate)
await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司')
for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value)
await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE')
field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01')
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate)
}
describe('new dispatch native layout', () => {
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
expect(dialog.text()).toContain('请先选择装车组')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
})
it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => {
const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
await externalResources(dialog, 'CA-EXTERNAL', '临时司机')
const state = wrapper.vm as unknown as { vehicle: Vehicle }
expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL')
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.text()).toContain('空单不能核平、装车或发车')
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
})
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises()
const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' })
vehicleSelect.vm.$emit('focus'); await flushPromises()
vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises()
const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' })
driverSelect.vm.$emit('focus'); await flushPromises()
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
expect(dialog.text()).not.toContain(vehicleRecord.id)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
})
it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
expect(wrapper.emitted('created')).toBeUndefined()
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
const { wrapper, dialog } = await render(); await restore(dialog); await externalResources(dialog, 'CA-A', '司机甲')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(dialog.text()).toContain('new-dispatch-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
})
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
rows[0] = sharedPalletCandidates()[0]!
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
const { dialog } = await render(); await selectWarehouse(dialog)
const table = dialog.get('[aria-label="排车候选货物"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('PALLET-24')
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
const sourceGroup = table.findAll('details')[0]!
await sourceGroup.get('summary').trigger('click'); await flushPromises()
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
expect(sourceGroup.findAll('li')).toHaveLength(2)
expect(sourceGroup.text()).toContain('客户A')
expect(sourceGroup.text()).toContain('客户B')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
})
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
})
it('shows loading and initialization diagnostics without issuing create commands', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
const { dialog } = await render()
expect(dialog.text()).toContain('draft-load-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
expect(dialog.text()).toContain('draft-rejected-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
})
it('keeps creation unavailable without its write permission', async () => {
can.mockReturnValue(false)
const { dialog } = await render()
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
})
@@ -1,338 +1,130 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessage, ElMessageBox } from 'element-plus'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, lockDispatchDraftPallet, unlockDispatchDraftPallet, releaseDispatchDraft, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/dispatches', () => ({
listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(),
createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
releaseDispatchDraft: vi.fn(),
}))
const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) {
return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.())
} })
const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) {
return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载')
} })
const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN',
warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' }
const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT',
warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' }
beforeEach(() => vi.resetAllMocks())
function mountDraft() {
return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
ElTag: shell, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
}
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', warehousePalletId: '103', palletNo: 'PAL-C' }
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', warehousePalletId: '104', palletNo: 'PAL-D' }
const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [
{ id: 'container-1', capacity: 30, palletIds: ['11', '13'] },
{ id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] },
{ id: 'empty-container', capacity: 15, palletIds: [] },
] }
const expectedBatch = { draftToken: 'original-draft', groups: [
{ draftContainerId: 'container-1', planPalletIds: ['11', '13'], returnReceiptIds: [] },
{ draftContainerId: 'container-2', planPalletIds: ['14'], returnReceiptIds: ['22'] },
] }
const goodResult = [{ draftContainerId: 'container-2', dispatch: { id: '102', dispatchNo: 'DS-2', appointmentBatches: [] } },
{ draftContainerId: 'container-1', dispatch: { id: '101', dispatchNo: 'DS-1', appointmentBatches: [] } }]
async function submitDraft(wrapper: ReturnType<typeof mountDraft>) {
await wrapper.findAll('button').find(node => node.text() === '预排车')!.trigger('click')
await flushPromises()
}
it('keeps two explicit groups in one atomic request, omits empty containers and accepts reordered matching results', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
import AppDialog from '@/shared/components/AppDialog.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void>; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId)
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection }))
await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection])
wrapper.unmount()
})
it.each([
['one collapsed result', goodResult.slice(0, 1)],
['foreign container', [{ ...goodResult[0], draftContainerId: 'foreign' }, goodResult[1]]],
['duplicate container', [goodResult[0], goodResult[0]]],
['duplicate dispatch', [goodResult[0], { ...goodResult[1], dispatch: goodResult[0].dispatch }]],
['missing business number', [goodResult[0], { ...goodResult[1], dispatch: { id: '101', dispatchNo: '', appointmentBatches: [] } }]],
])('retains the original groups and token after %s', async (_label, result) => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(result)
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(wrapper.emitted('created')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, expectedBatch)
expect(preArrangeDispatch).not.toHaveBeenCalled()
wrapper.unmount()
it('allows one atomic removal of a restored group that is no longer selectable', async () => {
const first = sharedPalletCandidates()[0]!, original = draft()
first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] }
original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original)
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId)
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
})
it('retains all groups on batch rejection without issuing a flat request', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockRejectedValue(new Error('草稿来源已变化'))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toBeUndefined()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
wrapper.unmount()
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options)
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
} finally { wrapper.unmount() }
})
it('does not submit a physical pallet fallback when its actual plan source identity is missing', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: undefined }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('来源标识缺失'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: recordVersion as Version, sources: [] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options)
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('submits relabel physical pallet identity without requiring an inbound plan pallet', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'relabel-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: null, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-1', appointmentBatches: [] })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise<void> }
state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
state.warehouseId = '321'; vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
await state.submit(); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', cargo: [], vehicle: null, driver: null }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('does not send a second batch while the original command is pending', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
let resolveCommand!: (result: typeof goodResult) => void
vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
expect(wrapper.emitted('created')).toBeUndefined()
resolveCommand(goodResult); await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('restores an explicit draft and submits complete server source versions in each container', async () => {
const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); original.containers[0]!.sourceKeys.push('source-B')
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('keeps group membership frozen while the submitted batch is awaiting its response', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
let resolveCommand!: (result: typeof goodResult) => void
vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click')
await wrapper.findAll('button').find(node => node.attributes('aria-label') === '删除装车车型1')!.trigger('click')
await flushPromises()
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
expect(deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
resolveCommand(goodResult); await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
})
it('retains unresolved sources when retry hydration still cannot recover the complete original draft', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, returned])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each([
{ ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['11'] }] },
{ ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c1', capacity: 30, palletIds: ['13'] }] },
{ ...twoGroups, containers: [{ id: '', capacity: 30, palletIds: ['11'] }] },
])('rejects duplicate sources or invalid container identities before writing', async draft => {
vi.mocked(listDispatchDrafts).mockResolvedValue([draft])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount()
})
it('rejects a relabel order split across multiple containers before either API command', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['101'] }, { id: 'c2', capacity: 30, palletIds: ['103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('一组排车'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; vehicle: { vehicleId: string | null }; form: { remark: string } }
state.restoreDraft('server-token'); await flushPromises(); state.vehicle.vehicleId = 'vehicle-A'; state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.vehicle.vehicleId = 'vehicle-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle?.vehicleId, group.dispatch.remark])).toEqual([['vehicle-A', '车甲'], ['vehicle-B', '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('keeps one complete relabel order on the single-dispatch endpoint', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
{ id: 'c1', capacity: 30, palletIds: ['101', '103'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL', appointmentBatches: [] })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101', '103'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
it('never sends an existing departure command through the empty creation transport', async () => {
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
})
it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => {
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container', dispatch: { id: '1', dispatchNo: 'DS-1', appointmentBatches: [] } }])
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }])
vi.mocked(listDispatchCandidates)
.mockResolvedValueOnce([plan])
.mockRejectedValueOnce(new Error('恢复来源失败'))
.mockResolvedValueOnce([plan])
.mockResolvedValueOnce([plan, returned])
const wrapper = shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
} })
await flushPromises()
const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')!
await submit().trigger('click')
await flushPromises()
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.get('[data-test="retry"]').trigger('click')
await flushPromises()
await submit().trigger('click')
await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith({ draftToken: 'original-draft', groups: [{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: ['22'] }] })
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' })
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
it('shows a draft recovery error and only loads candidates after a successful retry with the original token', async () => {
vi.mocked(listDispatchDrafts).mockRejectedValueOnce(new Error('草稿查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('keeps local relabel choices on close and reopen while describing their actual unlocked state', async () => {
const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel])
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('已选择,尚未锁定')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已选择,尚未锁定')
expect(listDispatchDrafts).toHaveBeenCalledTimes(2)
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click'); await flushPromises()
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each(['RELABEL_APPLICATION', 'RETURN_RELABEL'])('rejects a partial %s application after refreshing its complete candidate set', async orderType => {
const relabel = { ...plan, sourceStage: 'RELABEL' as const, orderRefs: 'RL-1', orderType }
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: ['101'] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...relabel, planPalletId: '13', warehousePalletId: '103' }])
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(listDispatchCandidates).toHaveBeenLastCalledWith({ sourceStage: 'RELABEL', orderNo: 'RL-1', draftToken: 'local-relabel' })
expect(preArrangeDispatch).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')?.message).toContain('全部应排托盘')
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.emitted('created')).toBeUndefined()
wrapper.unmount()
})
it('preserves all previous selections when reopening fails to read drafts, then recovers the same groups on retry', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockRejectedValueOnce(new Error('查询超时')).mockResolvedValueOnce([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('button').find(button => button.text() === '预排车')!.attributes('disabled')).toBeDefined()
await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
await submitDraft(wrapper)
expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
wrapper.unmount()
})
it('requires explicit release to restart an expired draft and keeps choices when release fails', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockResolvedValue([])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
vi.mocked(releaseDispatchDraft).mockRejectedValueOnce(new Error('释放超时')).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原草稿已失效')
expect(releaseDispatchDraft).not.toHaveBeenCalled()
const restart = () => wrapper.findAll('button').find(button => button.text() === '释放草稿并重新选择')!
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenLastCalledWith('original-draft')
expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原选择已保留')
await restart().trigger('click'); await flushPromises()
expect(releaseDispatchDraft).toHaveBeenNthCalledWith(2, 'original-draft')
expect(wrapper.find('[data-test="retry"]').exists()).toBe(false)
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
wrapper.unmount()
})
it.each([plan, returned])('does not claim a failed source lock succeeded for $sourceType', async candidate => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'lock-draft', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([candidate])
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' } } }).mockResolvedValueOnce(null)
const wrapper = mountDraft(); await flushPromises()
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')).toMatchObject({ message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' })
wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
expect(wrapper.text()).toContain('已锁定')
expect(lockDispatchDraftPallet).toHaveBeenLastCalledWith(candidate.sourceType === 'RETURN_RECEIPT' ? 'RETURN:22' : '11', 'lock-draft', 'c1')
wrapper.unmount()
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount()
})
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { ApiResponseError } from '@/shared/api/http'
import WarehouseTransferFulfillmentPanel from './WarehouseTransferFulfillmentPanel.vue'
import { allocateWarehouseTransfer, getWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
import { listWarehouseTransferSources } from '../api/warehouseTransferSources'
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
const { push, can } = vi.hoisted(() => ({ push: vi.fn(), can: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
@@ -21,8 +22,15 @@ function fixture(transferNo = 'WT-1', values: Partial<WarehouseTransferDetailDto
function render(customerId = 'customer-1', transferNo = 'WT-1') {
return mount(WarehouseTransferFulfillmentPanel, { props: { customerId, transferNo }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub } } })
}
const sourceCandidate: WarehouseTransferSourceCandidate = { inventoryPositionId: '9223372036854775001', palletId: '9223372036854775011', inboundCode: 'IN-1', skuCode: 'SKU-A', productNameZh: '测试货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }
async function openNativeAllocation() {
const wrapper = mount(WarehouseTransferFulfillmentPanel, { attachTo: document.body, props: { customerId: 'customer-1', transferNo: 'WT-1' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
return { wrapper, dialog: wrapper.getComponent(AppDialog) }
}
describe('WarehouseTransferFulfillmentPanel', () => {
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture()) })
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset().mockResolvedValue(fixture()); vi.mocked(allocateWarehouseTransfer).mockReset(); vi.mocked(listWarehouseTransferSources).mockReset() })
it('requires manage and inventory read permissions before offering allocation', async () => {
can.mockImplementation(code => code !== 'lastleg:warehouse-transfer:manage')
const wrapper = render(); await flushPromises()
@@ -190,4 +198,67 @@ describe('WarehouseTransferFulfillmentPanel', () => {
await wrapper.findAll('button').find(x => x.text().includes('重新加载'))!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('RL-RETRY')
})
it('retains partial and complete selections through native fullscreen and submits every exact source', async () => {
const second = { ...sourceCandidate, inventoryPositionId: '9223372036854775002', palletId: '9223372036854775012', palletNo: 'DP-2', skuCode: 'SKU-B' }
const blocked = { ...sourceCandidate, inventoryPositionId: 'BLOCKED', palletNo: 'DP-BLOCKED', canSelect: false, availablePackages: 0, blockReason: '当前无可分配箱数' }
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ candidates: [sourceCandidate, second, blocked], issues: [] })
vi.mocked(allocateWarehouseTransfer).mockResolvedValue(fixture('WT-1', { status: 'ALLOCATED' }))
const { dialog } = await openNativeAllocation()
expect(dialog.props('title')).toBe('选择本票库存来源 · WT-1')
expect(dialog.get('label[aria-label="选择 DP-BLOCKED SKU-A"] input').attributes('disabled')).toBeDefined()
await dialog.get('label[aria-label="选择 DP-1 SKU-A"] input').setValue(true); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
await dialog.get('label[aria-label="选择 DP-2 SKU-B"] input').setValue(true); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.get('[aria-label="库存分配核对"]').text()).toContain('已选择2 箱')
expect(dialog.props('confirmDisabled')).toBe(false)
await dialog.findAll('button').find(button => button.text() === '确认库存分配')!.trigger('click'); await flushPromises()
expect(allocateWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-1', {
idempotencyKey: expect.any(String), allocations: [
{ inventoryPositionId: sourceCandidate.inventoryPositionId, palletId: sourceCandidate.palletId, packageCount: 1 },
{ inventoryPositionId: second.inventoryPositionId, palletId: second.palletId, packageCount: 1 },
],
})
expect(dialog.props('modelValue')).toBe(false)
})
it('locks the native frame while saving and retains the identical command after an uncertain failure', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ candidates: [sourceCandidate], issues: [] })
let reject!: (reason: unknown) => void
vi.mocked(allocateWarehouseTransfer).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce(fixture('WT-1', { status: 'ALLOCATED' }))
const { wrapper, dialog } = await openNativeAllocation()
await dialog.get('label[aria-label="选择 DP-1 SKU-A"] input').setValue(true); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(allocateWarehouseTransfer).toHaveBeenCalledTimes(1)
const original = vi.mocked(allocateWarehouseTransfer).mock.calls[0]!
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[aria-label="DP-1 分配箱数"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('库存分配响应未确认', 'A0503', 503, null, 'allocation-frame-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('allocation-frame-request')
expect(dialog.get('input[aria-label="DP-1 分配箱数"]').attributes('disabled')).toBeDefined()
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '重试本次库存分配')!.trigger('click'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '重试本次分配')!.trigger('click'); await flushPromises()
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]).toEqual(original)
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
})
it('retries source errors in the native frame and keeps a long candidate table in the main body', async () => {
const candidates = Array.from({ length: 25 }, (_, index) => ({ ...sourceCandidate, inventoryPositionId: `POSITION-${index}`, palletId: `PALLET-${index}`, palletNo: `DP-${index}` }))
vi.mocked(listWarehouseTransferSources).mockRejectedValueOnce(new ApiResponseError('来源读取失败', 'A0503', 503, null, 'source-frame-request')).mockResolvedValueOnce({ candidates, issues: ['绑定资料待核查'] })
const { dialog } = await openNativeAllocation()
expect(dialog.text()).toContain('source-frame-request')
expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '重新加载')!.trigger('click'); await flushPromises()
const table = dialog.get('[aria-label="本票来源库存"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('DP-24')
expect(dialog.findComponent(DataTable).props('maxHeight')).toBe('')
expect((dialog.get('.el-table__body-wrapper .el-scrollbar__wrap').element as HTMLElement).style.maxHeight).toBe('')
expect(dialog.text()).toContain('绑定资料待核查')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
@@ -164,18 +165,24 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
</template>
</section>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" title="选择本票库存来源" width="min(1320px, calc(100vw - 40px))" :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<p>{{ transferNo }} · 本票 {{ detail?.packageCount }} 箱 · 已选择 {{ selectedPackages }} 箱</p>
<p v-if="originalCommand && !allocationRejected" role="status">分配结果尚未确认,已保留本次选择,重试使用相同内容。</p>
<p v-if="allocationRejected" role="status">本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。</p>
<section v-if="previousRejection" aria-label="上次分配诊断"><p>上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<p v-for="issue in sourceIssues" :key="issue" role="status">{{ issue }}</p>
<DataTable v-if="!sourceError" :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" :title="`选择本票库存来源 · ${transferNo || '—'}`" width="min(1320px, calc(100vw - 40px))" fullscreenable :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="库存分配核对"><h3 class="density-dialog-section__title">分配核对</h3>
<table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">本票箱数</th><td>{{ detail?.packageCount ?? '—' }} 箱</td><th scope="row">已选择</th><td>{{ selectedPackages }} 箱</td></tr></tbody></table>
</section>
<el-alert v-if="originalCommand && !allocationRejected" role="status" type="warning" :closable="false" show-icon title="分配结果尚未确认,已保留本次选择,重试使用相同内容。" />
<el-alert v-if="allocationRejected" role="status" type="warning" :closable="false" show-icon title="本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。" />
<section v-if="previousRejection" class="density-dialog-section" data-tone="source" aria-label="上次分配诊断"><h3 class="density-dialog-section__title">上次分配诊断</h3><p class="density-dialog-note">上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<el-alert v-for="issue in sourceIssues" :key="issue" role="status" :title="issue" type="warning" :closable="false" show-icon />
<section v-if="!sourceError" class="density-dialog-section" data-tone="source" aria-label="本票来源库存"><h3 class="density-dialog-section__title">来源库存</h3>
<DataTable :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" max-height="" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable>
</section>
</div>
</AppDialog>
</template>
@@ -1,18 +1,23 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WarehouseTransferReceiptPanel from './WarehouseTransferReceiptPanel.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, increaseWarehouseTransferForecast, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), getWarehouseTransferCartonLabel: vi.fn(), increaseWarehouseTransferForecast: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
enableAutoUnmount(afterEach)
const DataTableStub = defineComponent({
props: ['data', 'columns'],
setup(props, { slots }) { return () => h('div', { class: 'test-table' }, [JSON.stringify(props.data), ...(props.data || []).flatMap((row: object) => [slots.boxAction?.({ row }), slots.quantityDifference?.({ row }), slots.boxDifference?.({ row })])]) },
})
const DialogStub = defineComponent({ props: ['modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, slots.default?.()) : null } })
const DialogStub = defineComponent({ props: ['modelValue', 'confirmText', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', { 'data-test': 'box-dialog' }, [slots.default?.(), props.confirmText ? h('button', { disabled: props.confirmDisabled || props.confirmLoading, onClick: () => emit('confirm') }, props.confirmText) : null]) : null } })
const render = (transferNo = 'WT-SOURCE') => mount(WarehouseTransferReceiptPanel, { props: { customerId: 'CUSTOMER', transferNo }, global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, AppDialog: DialogStub } } })
function fixture(): WarehouseTransferDetailDto {
return {
@@ -27,7 +32,7 @@ function fixture(): WarehouseTransferDetailDto {
pallets: [{ id: '100', sequenceNo: 1, palletNo: 'PALLET-A', cartonCount: 2, grossWeightKg: 6, volumeCbm: .2, status: 'COMPLETED' }, { id: '200', sequenceNo: 2, palletNo: 'PALLET-B', cartonCount: 1, grossWeightKg: 4, volumeCbm: .1, status: 'COMPLETED' }], relabelBindings: [],
}
}
beforeEach(() => { vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset() })
beforeEach(() => { can.mockReset().mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockReset(); vi.mocked(getWarehouseTransferCartonLabel).mockReset(); vi.mocked(increaseWarehouseTransferForecast).mockReset() })
afterEach(() => vi.restoreAllMocks())
describe('转运预报与实际签收明细', () => {
it('keeps historical receipt totals after discard or shipment and leaves unknown declaration differences blank', async () => {
@@ -158,3 +163,153 @@ it('平台可补整单预报箱数,无需选择SKU', async () => {
expect(increaseWarehouseTransferForecast).toHaveBeenCalledWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
expect(wrapper.text()).toContain('预报箱数 6 箱')
})
async function nativeReceipt() {
const wrapper = mount(WarehouseTransferReceiptPanel, { attachTo: document.body, props: { customerId: 'CUSTOMER', transferNo: 'WT-SOURCE' }, global: { plugins: [ElementPlus], stubs: { transition: false } } })
await flushPromises()
return wrapper
}
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openNativeForecast() {
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
return { wrapper, dialog: popup(wrapper, '增加预报箱数') }
}
function forecastFixture() { return { ...fixture(), inboundCode: 'WT-SOURCE', status: 'WAIT_PDA_RECEIVING' } }
describe('receipt native popup integration', () => {
it('keeps counted boxes and mixed SKU contents through two independent fullscreen frames', async () => {
const value = fixture()
value.cartons[0] = { ...value.cartons[0]!, quantity: 10, discrepancyReason: '少收两件,客户确认', items: [{ transferItemId: '10', skuCode: 'SKU-A', quantity: 3 }, { transferItemId: '20', skuCode: 'SKU-B', quantity: 7 }] }
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click'); await flushPromises()
const cartonsDialog = popup(wrapper, '签收箱号明细')
expect(cartonsDialog.props('title')).toBe('签收箱号明细 · WT-SOURCE')
expect(cartonsDialog.get('[aria-label="已清点箱号"]').findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(3)
expect(cartonsDialog.text()).not.toContain('BOX-MISSING')
await cartonsDialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await cartonsDialog.findAll('.el-table__body-wrapper button').find(button => button.text() === '箱内明细')!.trigger('click'); await flushPromises()
const boxDialog = popup(wrapper, '箱内清点明细')
expect(boxDialog.props('title')).toBe('箱内清点明细 · BOX-1')
await boxDialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(boxDialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(boxDialog.findComponent(DataTable).props('data')).toEqual([{ sku: 'SKU-A', name: '产品 A', quantity: 3 }, { sku: 'SKU-B', name: '产品 B', quantity: 7 }])
expect(boxDialog.text()).toContain('少收两件,客户确认')
expect(boxDialog.get('[aria-label="当前签收箱"]').text()).toContain('2026-10-15')
await boxDialog.get('.el-dialog__headerbtn').trigger('click'); await new Promise(resolve => setTimeout(resolve, 80)); await flushPromises()
expect(boxDialog.props('modelValue')).toBe(false)
expect(cartonsDialog.props('modelValue')).toBe(true)
expect(cartonsDialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE')
})
it('retains every counted box in the main dialog body without a nested height cap', async () => {
const value = fixture()
value.cartons = Array.from({ length: 30 }, (_, index) => ({ ...value.cartons[0]!, cartonMark: `BOX-${index + 1}`, cartonIndex: index + 1, totalCartons: 30 }))
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
const wrapper = await nativeReceipt()
await wrapper.findAll('button').find(button => button.text() === '查看箱号明细')!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '签收箱号明细')
expect(dialog.get('[aria-label="实收箱数核对"]').text()).toContain('30 箱')
expect(dialog.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(30)
expect(dialog.text()).toContain('BOX-30')
expect(dialog.findComponent(DataTable).props('maxHeight')).toBe('')
expect((dialog.get('.el-table__body-wrapper .el-scrollbar__wrap').element as HTMLElement).style.maxHeight).toBe('')
})
it('freezes the original forecast count through fullscreen and sends only the scoped count command', async () => {
const value = forecastFixture()
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
vi.mocked(increaseWarehouseTransferForecast).mockResolvedValue({ ...value, packageCount: 6 })
const { wrapper, dialog } = await openNativeForecast()
expect(dialog.props('title')).toBe('增加预报箱数 · WT-SOURCE')
expect(dialog.get('[aria-label="预报箱数核对"]').findAll('input')).toHaveLength(0)
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('增加后6 箱')
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
await dialog.findAll('button').find(button => button.text() === '确认增加')!.trigger('click'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
expect(dialog.props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('预报箱数 6 箱')
})
it('locks pending edits and closing, preserves the failed count and retries the same snapshot', async () => {
const value = forecastFixture()
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
let reject!: (reason: unknown) => void
vi.mocked(increaseWarehouseTransferForecast).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...value, packageCount: 6 })
const { dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="请输入新增箱数"]').attributes('disabled')).toBeDefined()
expect(dialog.findAll('button').find(button => button.text() === '取消')!.attributes('disabled')).toBeDefined()
reject(new ApiResponseError('箱数已被其他操作修改', 'A0409', 409, null, 'forecast-frame-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
expect(dialog.text()).toContain('forecast-frame-request')
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('原预报箱数4 箱')
await dialog.findAll('button').find(button => button.text() === '重试本次增加')!.trigger('click'); await flushPromises()
expect(vi.mocked(increaseWarehouseTransferForecast).mock.calls).toEqual([
['CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 }],
['CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 }],
])
})
it('keeps whole-box and 5000-box limits at both the frame and submit handler', async () => {
const value = { ...forecastFixture(), packageCount: 4999 }
vi.mocked(getWarehouseTransfer).mockResolvedValue(value)
vi.mocked(increaseWarehouseTransferForecast).mockResolvedValue({ ...value, packageCount: 5000 })
const { dialog } = await openNativeForecast()
const input = dialog.findComponent({ name: 'ElInputNumber' })
expect(input.props('max')).toBe(1)
for (const count of [0, -1, 1.5, Number.NaN, 2]) {
input.vm.$emit('update:modelValue', count)
dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).not.toHaveBeenCalled()
input.vm.$emit('update:modelValue', 1); await flushPromises()
}
input.vm.$emit('update:modelValue', 1); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4999, packageCount: 5000 })
})
it('resets a cancelled draft and its failure context when reopened', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValue(forecastFixture())
vi.mocked(increaseWarehouseTransferForecast).mockRejectedValue(new ApiResponseError('增加失败', 'A0503', 503, null, 'old-forecast-request'))
const { wrapper, dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(dialog.text()).toContain('old-forecast-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
expect(increaseWarehouseTransferForecast).toHaveBeenCalledTimes(1)
})
it('closes the old forecast frame on a scope change and ignores a late failure', async () => {
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(forecastFixture()).mockResolvedValueOnce({ ...forecastFixture(), transferNo: 'WT-NEW', inboundCode: 'WT-NEW', packageCount: 8 })
let reject!: (reason: unknown) => void
vi.mocked(increaseWarehouseTransferForecast).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { wrapper, dialog } = await openNativeForecast()
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
await wrapper.setProps({ customerId: 'CUSTOMER-NEW', transferNo: 'WT-NEW' }); await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
reject(new ApiResponseError('旧票响应', 'A0503', 503, null, 'stale-forecast-request')); await flushPromises()
expect(wrapper.text()).toContain('预报箱数 8 箱')
expect(wrapper.text()).not.toContain('stale-forecast-request')
await wrapper.findAll('button').find(button => button.text() === '增加预报箱数')!.trigger('click'); await flushPromises()
expect(dialog.props('title')).toBe('增加预报箱数 · WT-NEW')
expect(dialog.get('[aria-label="预报箱数核对"]').text()).toContain('原预报箱数8 箱')
expect(dialog.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
expect(increaseWarehouseTransferForecast).toHaveBeenCalledExactlyOnceWith('CUSTOMER', 'WT-SOURCE', { expectedPackageCount: 4, packageCount: 6 })
})
it('keeps forecast modification unavailable without approval permission', async () => {
can.mockReturnValue(false)
vi.mocked(getWarehouseTransfer).mockResolvedValue(forecastFixture())
const wrapper = await nativeReceipt()
expect(wrapper.findAll('button').some(button => button.text() === '增加预报箱数')).toBe(false)
popup(wrapper, '增加预报箱数').vm.$emit('confirm'); await flushPromises()
expect(increaseWarehouseTransferForecast).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,12 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Eye, Printer } from '@lucide/vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getWarehouseTransfer, getWarehouseTransferCartonLabel, increaseWarehouseTransferForecast, type WarehouseTransferCartonDto, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
@@ -20,20 +22,25 @@ const increaseOpen = ref(false)
const increasing = ref(false)
const additionalCount = ref(1)
const forecastSnapshot = ref(0)
const increaseError = ref<ApiErrorDetails | null>(null)
const canIncreaseForecast = computed(() => access.can(permission.warehouseTransferApprove) && detail.value?.transferNo === detail.value?.inboundCode && ['PENDING_REVIEW', 'WAIT_PDA_RECEIVING'].includes(detail.value?.status || '') && Number(detail.value?.packageCount) < 5000)
const forecastInputValid = computed(() => Number.isSafeInteger(additionalCount.value) && additionalCount.value >= 1 && forecastSnapshot.value + additionalCount.value <= 5000)
/** 记录打开时箱数,提交时由后端核对并发修改。 */
function openIncreaseForecast() { forecastSnapshot.value = Number(detail.value?.packageCount); additionalCount.value = 1; increaseOpen.value = true }
function openIncreaseForecast() {
if (increasing.value || !canIncreaseForecast.value) return
forecastSnapshot.value = Number(detail.value?.packageCount); additionalCount.value = 1; increaseError.value = null; increaseOpen.value = true
}
/** 只补充箱号,不改变原SKU预报或仓库已录入的测量。 */
async function saveIncreaseForecast() {
if (increasing.value || !Number.isSafeInteger(additionalCount.value) || additionalCount.value < 1) return
if (increasing.value || !increaseOpen.value || !canIncreaseForecast.value || !forecastInputValid.value) return
const ticket = generation
increasing.value = true
increasing.value = true; increaseError.value = null
try {
const value = await increaseWarehouseTransferForecast(props.customerId, props.transferNo, { expectedPackageCount: forecastSnapshot.value, packageCount: forecastSnapshot.value + additionalCount.value })
if (ticket !== generation) return
detail.value = value; increaseOpen.value = false
ElMessage.success('预报箱数已增加,请通知PDA刷新后继续清点')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '增加预报箱数失败').message) }
} catch (cause) { if (ticket === generation) increaseError.value = getApiErrorDetails(cause, '增加预报箱数失败') }
finally { increasing.value = false }
}
const printing = ref(false)
@@ -131,7 +138,7 @@ async function printCartons() {
}
async function load() {
const ticket = ++generation
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; error.value = null; printError.value = null
detail.value = null; selectedBox.value = null; boxOpen.value = false; cartonsOpen.value = false; increaseOpen.value = false; increaseError.value = null; error.value = null; printError.value = null
if (!props.customerId || !props.transferNo) { loading.value = false; error.value = { message: '缺少客户或来源转运单号,无法查询签收明细' }; return }
loading.value = true
try { const value = await getWarehouseTransfer(props.customerId, props.transferNo); if (ticket === generation) detail.value = value }
@@ -147,7 +154,7 @@ onBeforeUnmount(() => { generation++ })
<LoadErrorState v-if="error" title="签收明细加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<p v-for="reason in discrepancyReasons" :key="reason" class="receipt-discrepancy-reason"><strong>实收差异原因:</strong>{{ reason }}</p>
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" :disabled="increasing" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
<DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
<template #quantityDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.quantityDifference) !== 0 }">{{ differenceText(row.quantityDifference) }}</span></template>
@@ -156,17 +163,30 @@ onBeforeUnmount(() => { generation++ })
<h3>托盘签收汇总</h3><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" />
<div class="receipt-box-heading"><h3>签收箱号明细({{ cartons.length }} 箱)</h3><el-button type="primary" size="small" :disabled="!cartons.length" @click="cartonsOpen = true">查看箱号明细</el-button><el-button v-if="detail.status !== 'DRAFT'" type="primary" size="small" :loading="printing" :disabled="!detail.cartons.length" @click="printCartons"><Printer :size="14" />打印箱唛</el-button></div>
<LoadErrorState v-if="printError" title="箱唛打印失败" :message="printError.message" :loading="printing" @retry="printCartons"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" title="签收箱号明细" width="1120px" :show-footer="false"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
</DataTable></AppDialog>
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" :title="`签收箱号明细 · ${transferNo || '—'}`" width="min(1120px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="实收箱数核对"><h3 class="density-dialog-section__title">实收汇总</h3><table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">已清点箱数</th><td>{{ cartons.length }} 箱</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="已清点箱号"><h3 class="density-dialog-section__title">签收箱号</h3><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" max-height="" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
</DataTable></section>
</div>
</AppDialog>
</template>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" title="增加预报箱数" width="520px" :show-footer="false">
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
<p>保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</p>
<el-button :disabled="increasing" @click="increaseOpen = false">取消</el-button><el-button type="primary" :loading="increasing" @click="saveIncreaseForecast">确认增加</el-button>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" :title="`增加预报箱数 · ${transferNo || '—'}`" width="min(520px, calc(100vw - 28px))" fullscreenable confirm-text="确认增加" :confirm-loading="increasing" :confirm-disabled="!canIncreaseForecast || !forecastInputValid" @confirm="saveIncreaseForecast">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="预报箱数核对"><h3 class="density-dialog-section__title">箱数核对</h3><table class="density-table"><tbody><tr><th scope="row">原预报箱数</th><td>{{ forecastSnapshot }} 箱</td><th scope="row">增加后</th><td>{{ forecastInputValid ? forecastSnapshot + additionalCount : '—' }} 箱</td></tr></tbody></table></section>
<LoadErrorState v-if="increaseError" title="预报箱数增加失败" :message="increaseError.message" compact :loading="increasing" retry-text="重试本次增加" @retry="saveIncreaseForecast"><template #details><ApiErrorMeta :details="increaseError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="新增箱数登记"><h3 class="density-dialog-section__title">增加预报 <InlineHelp title="增加预报说明">保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="100px" :disabled="increasing"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="新增箱数" required><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" controls-position="right" /></el-form-item></td></tr></tbody></table></el-form>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="boxOpen" title="箱内清点明细" width="820px" :show-footer="false">
<template v-if="selectedBox"><p>{{ selectedBox.cartonMark }} · 清点时间 {{ formatOverseasDateTime(selectedBox.countedAt) }}</p><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" :show-pagination="false" /><p v-if="selectedBox.discrepancyReason">差异说明:{{ selectedBox.discrepancyReason }}</p></template>
<AppDialog class="admin-density-dialog" v-model="boxOpen" :title="`箱内清点明细 · ${selectedBox?.cartonMark || '—'}`" width="min(820px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div v-if="selectedBox" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="当前签收箱"><h3 class="density-dialog-section__title">签收箱</h3><table class="density-table"><tbody><tr><th scope="row">箱唛</th><td>{{ selectedBox.cartonMark }}</td><th scope="row">清点时间</th><td>{{ formatOverseasDateTime(selectedBox.countedAt) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="箱内SKU实收"><h3 class="density-dialog-section__title">箱内实收</h3><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" max-height="" :show-pagination="false" /></section>
<el-alert v-if="selectedBox.discrepancyReason" :title="`差异说明:${selectedBox.discrepancyReason}`" type="warning" :closable="false" show-icon />
</div>
</AppDialog>
</section>
</template>
@@ -37,7 +37,8 @@ describe('WarehouseTransferReviewDialog', () => {
it('shows a concise confirmation without fetching detail tables or review inputs', () => {
const wrapper = mountDialog()
expect(wrapper.text()).toContain('仓储转运单审核')
expect(wrapper.text()).toContain('仓储转运单审核 · WT-1')
expect(wrapper.text()).toContain('测试客户')
expect(wrapper.text()).toContain('审核通过后,转运单将进入海外仓收货打托作业。')
expect(wrapper.find('.back').text()).toBe('返回')
expect(wrapper.find('.approve').text()).toBe('审核通过')
@@ -31,7 +31,7 @@ async function submit() {
<AppDialog
v-model="open"
class="admin-density-dialog density-confirm-dialog"
title="仓储转运单审核"
:title="`仓储转运单审核 · ${transferNo || '—'}`"
width="min(420px, calc(100vw - 28px))"
align-center
cancel-text="返回"
@@ -39,6 +39,6 @@ async function submit() {
:confirm-loading="saving"
@confirm="submit"
>
<div>审核通过后,转运单将进入海外仓收货打托作业。</div>
<section class="density-dialog-section" data-tone="source" aria-label="转运审核对象"><p class="density-dialog-note">客户:{{ customerName || '—' }}</p><div>审核通过后,转运单将进入海外仓收货打托作业。</div></section>
</AppDialog>
</template>
@@ -0,0 +1,63 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
import { command } from './dispatchTestFixtures'
beforeEach(() => sessionStorage.clear())
it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('DEPART'))
const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
await recovery.execute(send, read, find)
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
expect(recovery.pending.value).toBeNull()
})
it('keeps a proven result across GET failure and does not send the command a second time', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
await recovery.execute(send, read, find)
expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
})
it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
expect(second.pending.value?.dispatchNo).toBe('PC/1')
expect(() => second.begin('', 'CREATE', { idempotencyKey: 'new' })).toThrow('原请求')
})
it('does not discard an earlier unknown write on a later business rejection', async () => {
const recovery = useDispatchCommandRecovery(() => crypto.randomUUID())
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })).toThrow('账号')
const owner = crypto.randomUUID(), stable = useDispatchCommandRecovery(() => owner)
stable.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
})
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue(command('DEPART', 'different')), read, vi.fn())).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
})
it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'DEPART', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('DEPART'), recordVersion: '9007199254740993' }), find = vi.fn()
await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
expect(recovery.pending.value?.result).not.toBeNull()
await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
})
it('treats an internal server error as unknown instead of authorizing a new command', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
})
@@ -0,0 +1,82 @@
import { ref } from 'vue'
import { ApiResponseError } from '@/shared/api/http'
import type { CommandResult, DispatchAction } from './dispatchContracts'
import { requireVersion } from './dispatchContracts'
export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record<string, unknown>; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string }
const storageKey = (scope: string) => `dispatch-command:${scope}`
const snapshot = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const sessions = new Map<string, { pending: ReturnType<typeof ref<PendingDispatchCommand | null>>; busy: ReturnType<typeof ref<boolean>> }>()
export function isDefiniteDispatchRejection(error: unknown) {
return error instanceof ApiResponseError && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(error.code))
}
export function validateCommandResult(result: CommandResult, pending: PendingDispatchCommand) {
if (!result || result.dispatchNo !== pending.dispatchNo && pending.action !== 'CREATE'
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
requireVersion(result.recordVersion)
if (pending.action === 'DEPART' && result.stage !== 'DELIVERING') throw new Error('发车结果未确认派送中,请核对原请求')
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('装车结果未确认装车后,请核对原请求')
}
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
if (current?.dispatchNo !== result.dispatchNo) throw new Error('恢复详情与原排车单不一致')
const latest = String(requireVersion(current.recordVersion)).replace(/^0+(?=\d)/, '')
const confirmed = String(requireVersion(result.recordVersion)).replace(/^0+(?=\d)/, '')
if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请保留原请求重读')
}
/** A response proves the original command; a subsequent GET supplies current UI facts. */
export function useDispatchCommandRecovery(scope: () => string) {
const owner = scope()
const saved = owner ? sessionStorage.getItem(storageKey(owner)) : null
const existing = sessions.get(owner)
const pending = existing?.pending || ref<PendingDispatchCommand | null>(null)
const busy = existing?.busy || ref(false)
if (!existing && saved) {
try { pending.value = JSON.parse(saved) as PendingDispatchCommand }
catch { throw new Error('本次排车请求记录损坏,请保留现场核对') }
}
if (!existing) sessions.set(owner, { pending, busy })
function persist() {
if (!owner || owner !== scope()) throw new Error('账号已变化,请重新进入排车列表')
if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
else sessionStorage.removeItem(storageKey(owner))
}
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick<PendingDispatchCommand, 'operation' | 'itemId'> = {}) {
if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
pending.value = { dispatchNo, action, body: snapshot(body) as Record<string, unknown>, attempted: false, result: null, rejected: false, ...target }
persist()
}
async function execute(send: (command: PendingDispatchCommand) => Promise<CommandResult>, read: (dispatchNo: string) => Promise<unknown>, find: (dispatchNo: string, key: string) => Promise<CommandResult>) {
if (busy.value || !pending.value) return
const command = pending.value
const previouslyAttempted = command.attempted
busy.value = true
try {
persist()
if (!command.result && previouslyAttempted && command.dispatchNo) {
try { const found = await find(command.dispatchNo, String(command.body.idempotencyKey)); validateCommandResult(found, command); command.result = found }
catch (error) { if (!(error instanceof ApiResponseError) || !['404', 'A0404', 'HTTP_404'].includes(String(error.code))) throw error }
}
if (!command.result) {
command.attempted = true; persist()
try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
catch (error) {
// A later rejection cannot erase an earlier unknown write result.
if (!previouslyAttempted && isDefiniteDispatchRejection(error)) command.rejected = true
persist(); throw error
}
}
validateCommandResult(command.result, command); persist()
validateCurrentDispatch(await read(command.result.dispatchNo), command.result)
pending.value = null; persist()
return command.result
} finally { busy.value = false }
}
function discardRejected() {
if (busy.value || !pending.value?.rejected) return
pending.value = null; persist()
}
return { pending, busy, begin, execute, discardRejected }
}
@@ -0,0 +1,144 @@
// Models after the dispatch API boundary: Long quantities are validated safe numbers.
// dispatchWire.ts declares their HTTP string/number representation; IDs/versions remain lossless.
export type Version = string | number
export class DispatchInputError extends Error {}
export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type DispatchAction = 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
export type DispatchActions = Partial<Record<DispatchAction, Gate>>
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
export interface Reviews { dispatchReview: Review; loadingReview: Review }
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface ReasonCommand extends VersionCommand { reason: string }
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
export interface SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number }
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
export interface ConvertDraft extends VersionCommand { draftToken: string; groups: Array<{ draftContainerId: string; dispatch: CreateDispatch }> }
export interface ConvertedDraft { draftToken: string; vehicles: Array<{ draftContainerId: string; dispatch: CommandResult }>; replayed: boolean }
export interface CoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: string[] }
export interface FeeItem { feeType: string; feeName: string; direction: string; currency: string; amount: number | null; supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null; settlementMode?: string | null; paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[]; chargeableQuantity?: number | null; quantityUnit?: string | null; unitPrice?: number | null; periodStart?: string | null; periodEnd?: string | null; remark?: string | null }
export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
export interface SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null }
export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[]; sourceOrigin: SourceOrigin | null }
export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
export type CommandResult = CommandBase & (
{ action: 'PICK'; result: { dispatchItemId: string; positionId: string | null; returnCartonId: string | null; packages: number; pickedPackages: number; pickingReconciled: boolean } }
| { action: 'LOADING_COMPLETE'; result: { loadingCompletionHash: string; loadingCompletedAt: string; confirmedLoad: ConfirmedLoad } }
| { action: 'DEPART'; result: { departedAt: string; shippedPackages: number; outbounds: OutboundReference[]; deliveries: DeliveryReference[] } }
| { action: 'ARRANGE_DELIVERY'; result: ArrangementResult }
| { action: Exclude<DispatchAction, 'PICK' | 'LOADING_COMPLETE' | 'DEPART' | 'ARRANGE_DELIVERY'>; result: { dispatchNo: string; changedItemIds: string[] } }
)
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
{ value: 'AFTER_LOADING', label: '装车后' }, { value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
]
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
export const reviewLabels: Record<ReviewStatus, string> = { NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
export function requireVersion(value: unknown): Version {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
if (typeof value === 'string' && /^\d+$/.test(value)) return value
throw new DispatchInputError('操作版本缺失或无效,请刷新单据')
}
export function requireBatchVersions(values: readonly { batchKey: string; recordVersion: Version }[]): void {
const versions = new Map<string, string>()
for (const value of values) {
let version: Version
try { version = requireVersion(value.recordVersion) }
catch { throw new DispatchInputError('预约批次版本缺失或无效,请重新读取候选') }
const canonical = String(version).replace(/^0+(?=\d)/, '')
if (versions.has(value.batchKey) && versions.get(value.batchKey) !== canonical) throw new DispatchInputError('同一预约批次的版本不一致,请重新读取候选')
versions.set(value.batchKey, canonical)
}
}
function selectionIdentity(value: CargoSelection): string {
return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId])
}
export function requireSelectionGroup(row: Candidate): SelectionGroup {
const group = row.selectionGroup
if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选')
if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选')
const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo)
if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选')
if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选')
return group
}
export function selectionGroupBlockReason(row: Candidate): string {
try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' }
}
export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void {
const versions = new Map<string, string>()
for (const row of rows) {
const group = row.selectionGroup
if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
versions.set(group.groupKey, group.groupVersion)
}
for (const row of latest) {
const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey)
if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物')
}
}
export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] {
assertSelectionGroupVersions(rows, [])
const sources = new Map<string, SelectionGroupSource>(), selections = new Map<string, CargoSelection>()
for (const row of rows) for (const source of requireSelectionGroup(row).sources) {
const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity)
if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选')
if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选')
sources.set(source.sourceKey, source); selections.set(identity, source.selection)
}
return [...selections.values()]
}
@@ -0,0 +1,16 @@
import type { DispatchAction, DispatchActions } from './dispatchContracts'
export const dispatchActionLabels: Record<DispatchAction, string> = {
CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
}
export const dispatchActionPermissions: Record<DispatchAction, string> = {
CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
ADD_CARGO: 'platform:overseas-warehouse:dispatch:edit', REMOVE_CARGO: 'platform:overseas-warehouse:dispatch:edit',
CANCEL: 'platform:overseas-warehouse:dispatch:cancel', SUBMIT_REVIEW: 'platform:overseas-warehouse:dispatch:edit',
DISPATCH_REVIEW: 'platform:fleet:trip:dispatch-review', LOADING_REVIEW: 'platform:fleet:trip:loading-review',
RETURN_TO_REVIEW: 'platform:overseas-warehouse:dispatch:edit', PICK: 'lastleg:pda:operate',
LOADING_COMPLETE: 'platform:overseas-warehouse:dispatch:loading-complete', ARRANGE_DELIVERY: 'platform:overseas-warehouse:dispatch:transfer-delivery', DEPART: 'platform:overseas-warehouse:dispatch:depart',
}
export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
export const displayDispatchValue = (value: unknown) => value === null || value === undefined || value === '' ? '—' : String(value)
@@ -0,0 +1,43 @@
import { expect, it } from 'vitest'
import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts'
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
import { normalizeCandidateList, type CandidateWire } from './dispatchWire'
it('expands a filtered physical group into both exact relabel selections once while retaining one physical pallet', () => {
const [first, second] = sharedPalletCandidates()
expect(expandSelectionGroups([first!])).toEqual([first!.selection, second!.selection])
expect(expandSelectionGroups([first!, second!, first!])).toEqual([first!.selection, second!.selection])
expect(requireSelectionGroup(first!).pallets).toEqual([{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: 8 }])
})
it.each(['INBOUND_PLAN', 'RETURN_RECEIPT'] as const)('allows authoritative nullable business display fields for ordinary %s groups', sourceType => {
const row = candidate({ selection: { ...candidate().selection, sourceType, planPalletId: sourceType === 'INBOUND_PLAN' ? '11' : null, returnReceiptId: sourceType === 'RETURN_RECEIPT' ? '12' : null } })
expect(row.selectionGroup.sources[0]).toMatchObject({ sourceBusinessType: null, sourceBusinessNo: null })
expect(expandSelectionGroups([row])).toEqual([row.selection])
})
it('requires the actual RELABEL_ORDER business identity for relabel sources without accepting a legacy alias', () => {
const row = sharedPalletCandidates()[0]!
row.selectionGroup.sources[0]!.sourceBusinessNo = null
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
row.selectionGroup.sources[0]!.sourceBusinessNo = 'RL-A'; row.selectionGroup.sources[0]!.sourceBusinessType = 'RELABEL'
expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整')
})
it.each([undefined, { complete: false }, { blockReasons: ['换标来源尚未核齐'] }])('rejects a missing, incomplete or blocked selection group instead of falling back to the visible row', change => {
const row = sharedPalletCandidates()[0]!
row.selectionGroup = change ? { ...row.selectionGroup, ...change } : undefined as unknown as SelectionGroup
expect(() => expandSelectionGroups([row])).toThrow(/完整选货组|尚未完整|尚未核齐/)
})
it('rejects changed group composition and conflicting source versions before creating a command', () => {
const [first, second] = sharedPalletCandidates()
expect(() => assertSelectionGroupVersions([first!], [{ ...second!, selectionGroup: { ...second!.selectionGroup, groupVersion: 'composition-2' } }])).toThrow('组成已变化')
const conflict = { ...second!, selectionGroup: { ...second!.selectionGroup, sources: second!.selectionGroup.sources.map((source, index) => index ? source : { ...source, selection: { ...source.selection, sourceVersion: 'changed' } }) } }
expect(() => expandSelectionGroups([first!, conflict])).toThrow('来源事实不一致')
})
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
value.selectionGroup.sources[0]!.packages = '9007199254740993'
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
})
@@ -0,0 +1,19 @@
import type { Candidate, CommandResult, DispatchDetail, DispatchListRow, DispatchPage, Driver, LoadingPreview, VehicleSnapshot } from './dispatchContracts'
export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0 })
export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
export function sharedPalletCandidates(): Candidate[] {
const rows = ['A', 'B'].map((suffix, index) => candidate({ sourceKey: `RELABEL:${suffix}`, sourceVersion: `source-${suffix}`, customerId: `C${index + 1}`, customerName: `客户${suffix}`, availablePackages: index ? 5 : 3, selection: { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: `RL-${suffix}`, warehousePalletId: '21', sourceVersion: `source-${suffix}` } }))
const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] }
return rows.map(row => ({ ...row, selectionGroup: group }))
}
export function detail(overrides: Partial<DispatchDetail> = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1', sourceBusinessType: null, sourceBusinessNo: null }], ...overrides } }
export function listRow(overrides: Partial<DispatchListRow> = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
export function candidate(overrides: Partial<Candidate> = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } }
export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
}
export function loadingPreview(): LoadingPreview { return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', manifestHash: 'manifest', pickingReconciled: true, resources: resources(), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 2, palletSpaces: 1, weightKg: null, volumeCbm: null, deliveryIndex: 1, missingFields: ['weightKg', 'volumeCbm'], evidence: [{ field: 'weightKg', sourceType: 'ORDER_SNAPSHOT_ALLOCATION', sourceBusinessNo: 'IN-1', observedAt: null, value: 100, actual: false }] }], confirmedLoad: null, loadingComplete: { allowed: true, blockCode: null, blockReason: null }, missingFields: ['weightKg', 'volumeCbm'] } }
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { parseApiJsonResponse } from '@chongchong/shared'
import { candidate, command, detail, destination, loadingPreview, page } from './dispatchTestFixtures'
import * as wire from './dispatchWire'
// Exercise the same JSON parser as Axios; quoted Long values must reach the boundary intact.
const parsed = <T>(value: T): T => parseApiJsonResponse(JSON.stringify(value)) as T
const id = '9007199254740993'
const counts = { itemCount: '1', palletCount: '2', packageCount: '12', pickedPackageCount: '0', shippedPackageCount: '0' }
const gate = { allowed: true, blockCode: null, blockReason: null }
const source = () => ({ ...candidate(), customerId: id, availablePackages: '12' })
const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersion: id, warehouseId: id, status: 'ACTIVE', sources: [source()], containers: [{ draftContainerId: id, sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], expiresAt: '' })
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => {
const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' }
const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin }
const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] }
const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!
expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24)
cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null }
expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin })
cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull()
cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' }
expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数')
})
describe('native dispatch Long quantity boundary', () => {
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
const value = { ...source(), appointmentBatches: [reference] }
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
const existing = detail()
expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
})
it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
expect(() => wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
})
it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
})
it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
})
it('accepts zero and the safe integer limit in numeric and string form', () => {
for (const value of [0, '0', Number.MAX_SAFE_INTEGER, String(Number.MAX_SAFE_INTEGER)]) expect(wire.dispatchQuantity(value, '箱数')).toBe(Number(value))
})
it('normalizes all list, statistics and review counts while preserving versions and IDs', () => {
const value = page()
const result = wire.normalizeDispatchPage(parsed({ ...value, total: '8', page: '1', size: '10', pages: '1', records: [{ ...value.records[0]!, warehouseId: id, recordVersion: id, counts, reviews: { ...value.records[0]!.reviews, dispatchReview: { ...value.records[0]!.reviews.dispatchReview, reviewRound: id } } }], statistics: { matchedTotal: '8', activeTotal: '7', cancelledTotal: '1', stageCounts: { BEFORE_LOADING: '3', LOADING: '1', AFTER_LOADING: '1', DELIVERING: '1', DELIVERED: '1' }, beforeLoadingReviewCounts: { vehicleInfoTotal: '3', dispatchReview: { notSubmitted: '1', pending: '1', approved: '1', rejected: '0' }, loadingReview: { notSubmitted: '2', pending: '0', approved: '1', rejected: '0' } } } }))
expect(result).toMatchObject({ total: 8, page: 1, size: 10, pages: 1, statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: value.statistics.stageCounts, beforeLoadingReviewCounts: value.statistics.beforeLoadingReviewCounts } })
expect(result.records[0]).toMatchObject({ warehouseId: id, recordVersion: id, counts: { itemCount: 1, palletCount: 2, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: { dispatchReview: { reviewRound: id } } })
})
it('normalizes detail document quantities without changing nullable Integer measurements or versions', () => {
const value = detail()
const result = wire.normalizeDispatchDetail(parsed({ ...value, recordVersion: id, counts, documents: { ...value.documents, ...references() }, orderDetails: [{ ...value.orderDetails[0]!, palletCount: null }] }))
expect(result.recordVersion).toBe(id); expect(result.counts.packageCount).toBe(12); expect(result.orderDetails[0]!.palletCount).toBeNull()
expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
})
it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
const candidates = wire.normalizeCandidateList(parsed({ candidates: [source()], snapshotAt: '', complete: true }))
expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
const result = wire.normalizeDispatchDraft(parsed(draft()))
expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
})
it('normalizes actual item and loading quantities while keeping decimal measurements and integer positions', () => {
const value = source()
const items = wire.normalizeDispatchItems(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...value.selection, dispatchItemId: id, sourceKey: value.sourceKey, sourceVersion: value.sourceVersion, customerId: id, customerName: value.customerName, palletNo: value.palletNo, orderNo: value.orderNo, inboundCode: value.inboundCode, destination: value.destination, scheduledPackages: '12', pickedPackages: '0', remove: gate }] } satisfies wire.DispatchItemsWire))
expect(items.items[0]).toMatchObject({ dispatchItemId: id, scheduledPackages: 12, pickedPackages: 0 }); expect(items.recordVersion).toBe(id)
const preview = loadingPreview()
const loading = wire.normalizeLoadingPreview(parsed({ ...preview, recordVersion: id, pallets: [{ ...preview.pallets[0]!, warehousePalletId: id, scheduledPackages: '12', pickedPackages: '12', weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 }] }))
expect(loading.pallets[0]).toMatchObject({ warehousePalletId: id, scheduledPackages: 12, pickedPackages: 12, weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 })
})
it('normalizes file size before metadata and Blob validation without rounding file identity', () => {
const result = wire.normalizeEvidenceFile(parsed({ fileId: id, fileName: '证据.png', contentType: 'image/png', sizeBytes: '3' }))
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
})
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [], sourceOrigin: null }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
})
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
const base = { ...command('EDIT'), counts, recordVersion: id }
const pick = wire.normalizeDispatchCommand(parsed({ ...base, action: 'PICK', result: { dispatchItemId: id, positionId: id, returnCartonId: null, packages: '2', pickedPackages: '12', pickingReconciled: true } } satisfies wire.CommandResultWire))
expect(pick).toMatchObject({ recordVersion: id, counts: { packageCount: 12 }, result: { dispatchItemId: id, packages: 2, pickedPackages: 12 } })
const depart: wire.CommandResultWire = { ...base, action: 'DEPART', result: { departedAt: '', shippedPackages: '12', ...references() } }
expect(wire.normalizeDispatchCommand(parsed(depart))).toMatchObject({ result: { shippedPackages: 12, outbounds: [{ packageCount: 12 }], deliveries: [{ palletCount: 2 }] } })
const arranged: wire.CommandResultWire = { ...base, action: 'ARRANGE_DELIVERY', result: arrangement() }
expect(wire.normalizeDispatchCommand(parsed(arranged))).toMatchObject({ result: { recordVersion: id, deliveries: [{ packageCount: 12 }] } })
expect(wire.normalizeConvertedDraft(parsed({ draftToken: 'draft', vehicles: [{ draftContainerId: id, dispatch: depart }], replayed: true }))).toMatchObject({ vehicles: [{ draftContainerId: id, dispatch: { recordVersion: id, counts: { pickedPackageCount: 0 }, result: { shippedPackages: 12 } } }] })
})
it('rejects malformed nested quantities at the boundary', () => {
expect(() => wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [{ ...source(), availablePackages: '9007199254740992' }] }))).toThrow('安全整数')
expect(() => wire.normalizeEvidenceFile({ fileId: id, fileName: '', contentType: '', sizeBytes: null } as unknown as wire.DispatchEvidenceFileWire)).toThrow('有效非负整数')
expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
})
})
@@ -0,0 +1,84 @@
import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts, type SelectionGroup, type SourceOrigin } from './dispatchContracts'
// common-web serializes both Long and long as decimal strings. Only quantity
// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
export type LongQuantityWire = string | number
type LongFields<T, K extends keyof T> = Omit<T, K> & { [P in K]: LongQuantityWire }
type CountsWire = LongFields<Counts, keyof Counts>
type ReviewCountsWire = LongFields<ReviewCounts, keyof ReviewCounts>
type OutboundWire = LongFields<OutboundReference, 'packageCount' | 'palletCount'>
type DeliveryWire = LongFields<DeliveryReference, 'packageCount' | 'palletCount'>
type ReferencesWire = { outbounds: OutboundWire[]; deliveries: DeliveryWire[] }
type StatisticsWire = Omit<DispatchStatistics, 'matchedTotal' | 'activeTotal' | 'cancelledTotal' | 'stageCounts' | 'beforeLoadingReviewCounts'> & {
matchedTotal: LongQuantityWire; activeTotal: LongQuantityWire; cancelledTotal: LongQuantityWire
stageCounts: Record<DispatchStage, LongQuantityWire>
beforeLoadingReviewCounts: { vehicleInfoTotal: LongQuantityWire; dispatchReview: ReviewCountsWire; loadingReview: ReviewCountsWire }
}
type ListRowWire = Omit<DispatchListRow, 'counts'> & { counts: CountsWire }
export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' | 'size' | 'pages' | 'statistics'> & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
export type DispatchEvidenceFileWire = LongFields<DispatchEvidenceFile, 'sizeBytes'>
type ArrangementResultWire = Omit<ArrangementResult, keyof ReferencesWire> & ReferencesWire
export type SourceOriginWire = Omit<SourceOrigin, 'productQuantity'> & { productQuantity: LongQuantityWire | null }
type CargoLineWire = Omit<LongFields<CargoLine, 'packages' | 'productQuantity'>, 'sourceOrigin'> & { sourceOrigin: SourceOriginWire | null }
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: CargoLineWire[] }
export type ArrangementPreviewWire = Omit<ArrangementPreview, 'groups' | 'arrangement'> & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
type PickedWire = LongFields<Extract<CommandResult, { action: 'PICK' }>['result'], 'packages' | 'pickedPackages'>
type DepartedWire = Omit<Extract<CommandResult, { action: 'DEPART' }>['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
type WireCommand<C extends CommandResult> = C extends CommandResult ? Omit<C, 'counts' | 'result'> & { counts: CountsWire; result: C extends { action: 'PICK' } ? PickedWire : C extends { action: 'DEPART' } ? DepartedWire : C extends { action: 'ARRANGE_DELIVERY' } ? ArrangementResultWire : C['result'] } : never
export type CommandResultWire = WireCommand<CommandResult>
export type ConvertedDraftWire = Omit<ConvertedDraft, 'vehicles'> & { vehicles: Array<{ draftContainerId: string; dispatch: CommandResultWire }> }
export function dispatchQuantity(value: unknown, field: string): number {
if (typeof value !== 'number' && (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value))) throw new DispatchInputError(`排车响应${field}不是有效非负整数,请重新读取`)
const number = typeof value === 'number' ? value : Number(value)
if (!Number.isSafeInteger(number) || number < 0) throw new DispatchInputError(`排车响应${field}超出安全整数范围或无效,请重新读取`)
return number
}
function numbers<T, K extends keyof T>(value: LongFields<T, K>, fields: readonly K[]): T {
const result = { ...value }
for (const field of fields) Object.assign(result, { [field]: dispatchQuantity(value[field], String(field)) })
return result as T
}
const counts = (value: CountsWire) => numbers<Counts, keyof Counts>(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
const reviewCounts = (value: ReviewCountsWire) => numbers<ReviewCounts, keyof ReviewCounts>(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers<OutboundReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers<DeliveryReference, 'packageCount' | 'palletCount'>(row, ['packageCount', 'palletCount'])) })
const candidate = (value: CandidateWire): Candidate => {
requireBatchVersions(value.appointmentBatches)
const group = value.selectionGroup
if (!group || typeof group.groupKey !== 'string' || typeof group.groupVersion !== 'string' || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选')
return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages'), selectionGroup: { ...group, sources: group.sources.map(source => ({ ...source, packages: dispatchQuantity(source.packages, 'selectionGroup.sources.packages') })), pallets: group.pallets.map(pallet => ({ ...pallet, packageCount: dispatchQuantity(pallet.packageCount, 'selectionGroup.pallets.packageCount') })) } }
}
function statistics(value: StatisticsWire): DispatchStatistics {
return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record<DispatchStage, number>, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
}
export function normalizeDispatchPage(value: DispatchPageWire): DispatchPage { return { ...value, total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages'), records: value.records.map(row => ({ ...row, counts: counts(row.counts) })), statistics: statistics(value.statistics) } }
export function normalizeDispatchDetail(value: DispatchDetailWire): DispatchDetail { requireBatchVersions(value.dispatchInfo.appointmentBatches); return { ...value, counts: counts(value.counts), documents: { ...value.documents, ...references(value.documents) } } }
export function normalizeCandidateList(value: CandidateListWire): CandidateList { return { ...value, candidates: value.candidates.map(candidate) } }
export function normalizeDispatchDraft(value: DispatchDraftWire): DispatchDraft { return { ...value, sources: value.sources.map(candidate) } }
export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems { return { ...value, items: value.items.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
function normalizeSourceOrigin(value: SourceOriginWire | null): SourceOrigin | null {
if (value == null) return null
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DispatchInputError('排车响应原货源结构缺失,请重新读取')
return { ...value, productQuantity: value.productQuantity == null ? null : dispatchQuantity(value.productQuantity, 'sourceOrigin.productQuantity') }
}
export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity'), sourceOrigin: normalizeSourceOrigin(row.sourceOrigin) })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
const normalizedCounts = counts(value.counts)
switch (value.action) {
case 'PICK': return { ...value, counts: normalizedCounts, result: { ...value.result, packages: dispatchQuantity(value.result.packages, 'packages'), pickedPackages: dispatchQuantity(value.result.pickedPackages, 'pickedPackages') } }
case 'DEPART': return { ...value, counts: normalizedCounts, result: { ...value.result, shippedPackages: dispatchQuantity(value.result.shippedPackages, 'shippedPackages'), ...references(value.result) } }
case 'ARRANGE_DELIVERY': return { ...value, counts: normalizedCounts, result: arrangementResult(value.result) }
default: return { ...value, counts: normalizedCounts }
}
}
export function normalizeConvertedDraft(value: ConvertedDraftWire): ConvertedDraft { return { ...value, vehicles: value.vehicles.map(row => ({ ...row, dispatch: normalizeDispatchCommand(row.dispatch) })) } }
+6 -2
View File
@@ -6,8 +6,12 @@ export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmen
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
export { overseasWarehousePermissions } from './permissions'
export { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft } from './api/dispatches'
export type { DispatchBatchReference, BatchDispatchCandidate, BatchDispatchCandidates, DispatchBatchAllocation, DispatchSummaryDto, PreArrangeDispatchDraftPayload } from './api/dispatches'
export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
@@ -58,25 +58,28 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(sharedDetail).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('LastMileOrderDetailView.vue')).toContain('density-fields')
expect(page('InboundOrderDetailView.vue')).toContain('density-fields')
expect(page('DispatchDetailView.vue')).toContain('.document-section { grid-column: 1 / -1; }')
expect(page('DispatchDetailView.vue')).toContain('class="density-element-tabs density-contained-tabs"')
expect(page('DispatchDetailView.vue').match(/<el-tab-pane name=/g)).toHaveLength(4)
expect(page('DispatchDetailView.vue')).toContain('density-fields')
expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields')
expect(page('InventoryDetailView.vue')).toContain('density-fields')
expect(page('InventoryDetailView.vue').match(/class="inventory-table-wrap"/g)).toHaveLength(3)
expect(page('InventoryDetailView.vue')).toContain('.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }')
const outbounds = page('OutboundsView.vue')
expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }')
expect(outbounds).toContain('density-table density-table--editable outbound-editor-table')
const density = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
expect(density).toContain('.density-form :is(.el-select, .el-input-number, .el-date-editor) { width: 100%; }')
expect(outbounds).not.toContain('.create-outbound-form :deep(.el-select)')
expect(outbounds).toContain('.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-date-editor) { width: 100%; }')
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; }')
expect(page('RelabelOrdersView.vue')).toContain('density-table density-table--editable relabel-editor-table')
expect(page('PalletsView.vue')).toContain('density-table density-table--editable pallet-editor-table')
expect(page('RelabelOrdersView.vue')).toContain('label-position="left" label-width="120px"')
expect(page('PalletsView.vue')).toContain('label-position="left" label-width="120px"')
const lastMileDetail = page('LastMileOrderDetailView.vue')
expect(lastMileDetail).toContain('class="fee-form-grid"')
expect(lastMileDetail).toContain('density-table density-table--editable fee-editor-table')
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('.fee-editor-table { table-layout: fixed; }')
expect(lastMileDetail).toContain('.fee-editor-table td:not([colspan]) { width: 50%; }')
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; }')
@@ -0,0 +1,173 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards'
import CargoDiscardsView from './CargoDiscardsView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function summary(overrides: Partial<CargoDiscardSummary> = {}): CargoDiscardSummary { return { id: '9007199254740993', customerId: '9007199254740994', discardNo: 'CD-001', inboundCodes: 'INBOUND-1', overseasWarehouseName: '洛杉矶仓', outboundNo: 'OUT-1', status: 'PENDING_REVIEW', totalPackages: 2, totalFee: 20, currency: 'USD', ...overrides } }
function detail(): CargoDiscardDetail { return { summary: summary(), remark: '废弃原因与备注', items: [{ id: '9007199254740995', inboundCode: 'INBOUND-1', skuCode: 'SKU-LONG-ORIGINAL-001', productName: '原始完整品名'.repeat(10), discardPackages: 2, cartonMarks: ['MARK-001', 'MARK-002'] }], histories: [{ toStatus: 'PENDING_REVIEW', createdAt: '2026-10-20T09:00:00', remark: '已提交申请' }], photos: [] } }
function photo(fileId = '9007199254740996') { return { fileId, palletNo: 'PALLET-1', fileName: '废弃现场.png', createdAt: '2026-10-20T09:00:00', submittedAt: '2026-10-20T09:01:00' } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] })
api.list.mockResolvedValue({ records: [summary()], total: 1 })
api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail())
api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
URL.createObjectURL = vi.fn().mockReturnValue('blob:discard-photo'); URL.revokeObjectURL = vi.fn()
})
async function page() { const wrapper = mount(CargoDiscardsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function row(wrapper: VueWrapper, no = 'CD-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(no)).at(-1)! }
async function open() { const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() }
function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! }
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => {
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
const { dialog } = await open()
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
const facts = dialog.get('[aria-label="废弃单基础资料"]')
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
const clamps = dialog.findAllComponents(LineClampText).map(item => item.props('content'))
expect(clamps).toContain('原始完整品名'.repeat(10)); expect(clamps).toContain('MARK-001、MARK-002'); expect(clamps).toContain('SKU-LONG-ORIGINAL-001')
expect(dialog.get('[aria-label="废弃状态流转"]').text()).toContain('已提交申请')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.findAll('input, textarea')).toHaveLength(0); expect(api.review).not.toHaveBeenCalled()
})
it('locks reading and retries the original business number and customer scope after failure', async () => {
let reject!: (error: Error) => void
api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.find('[aria-label="废弃单基础资料"]').exists()).toBe(false)
reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'discard-detail-request')); await flushPromises()
expect(dialog.text()).toContain('discard-detail-request'); await retry(errorState(dialog, '废弃单详情加载失败'))
expect(api.detail.mock.calls).toEqual([['CD-001', '9007199254740994'], ['CD-001', '9007199254740994']])
expect(dialog.get('[aria-label="废弃单基础资料"]').text()).toContain('CD-001')
})
it('confirms approval with the business number and sends the original APPROVE request only once', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.review.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); const button = row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await button.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('CD-001'); expect(confirmation().text()).toContain('推送 PDA')
expect(confirmation().classes()).toContain('admin-density-confirm'); expect(api.review).not.toHaveBeenCalled()
await accept(); await button.trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledExactlyOnceWith('CD-001', 'APPROVE', undefined, '9007199254740994')
expect(row(wrapper).findAll('button').every(item => item.attributes('disabled') !== undefined)).toBe(true)
resolve(detail()); await flushPromises(); expect(api.list).toHaveBeenCalledTimes(2)
})
it('preserves the list and diagnostics on approval failure, and ignores native cancel and close', async () => {
api.review.mockRejectedValueOnce(new ApiResponseError('审核未确认', 'A0503', 503, null, 'discard-review-request'))
const wrapper = await page()
const approve = () => row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await approve().trigger('click'); await flushPromises(); await accept()
expect(wrapper.text()).toContain('discard-review-request'); expect(row(wrapper).text()).toContain('CD-001')
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce()
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__headerbtn').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce(); expect(errorState(wrapper, '审核失败')).toBeUndefined()
})
it('keeps list access readonly, checks approval status and rechecks permission after confirmation', async () => {
can.mockImplementation(code => code === permission.discardList)
const readonly = await page(); expect(row(readonly).findAll('button').map(item => item.text())).toEqual(['详情'])
can.mockReturnValue(true); api.list.mockResolvedValueOnce({ records: [summary({ status: 'APPROVED' })], total: 1 })
const approved = await page(); expect(row(approved).findAll('button').map(item => item.text())).toEqual(['详情'])
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!.trigger('click'); await flushPromises()
can.mockImplementation(code => code !== permission.discardApprove); await accept(); expect(api.review).not.toHaveBeenCalled()
})
it('downloads each photo in its explicit scope and keeps native viewer order, size and cleanup', async () => {
const result = detail(); result.photos = [photo(), photo('9007199254740997')]
const first = new Blob(['first'], { type: 'image/png' }); const second = new Blob(['second'], { type: 'image/png' })
let resolve!: (value: Blob) => void
api.detail.mockResolvedValueOnce(result)
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(second)
vi.mocked(URL.createObjectURL).mockImplementation(blob => blob === first ? 'blob:first' : 'blob:second')
const { dialog } = await open(); expect(dialog.text()).toContain('照片加载中')
resolve(first); await flushPromises()
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740997', '9007199254740994']])
const images = dialog.findAllComponents({ name: 'ElImage' }); expect(images).toHaveLength(2)
expect(images[0]!.props()).toMatchObject({ src: 'blob:first', previewSrcList: ['blob:first', 'blob:second'], initialIndex: 0, previewTeleported: true, fit: 'cover' })
expect(images[1]!.props('initialIndex')).toBe(1)
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:first'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:second')
})
it('shows per-photo diagnostics and retries the same file, business number and customer', async () => {
const result = detail(); result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
api.photo.mockRejectedValueOnce(new ApiResponseError('图片失败', 'A0503', 503, null, 'discard-photo-request'))
const { dialog } = await open(); expect(dialog.text()).toContain('discard-photo-request')
await retry(errorState(dialog, '废弃照片加载失败'))
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740996', '9007199254740994']])
expect(dialog.findComponent({ name: 'ElImage' }).exists()).toBe(true)
})
it('ignores late photos after closing and switching to another business number', async () => {
const old = detail(); old.photos = [photo()]; api.detail.mockResolvedValueOnce(old)
let resolve!: (value: Blob) => void
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await open(); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
const next = detail(); next.summary = summary({ discardNo: 'CD-002', customerId: 'CUSTOMER-2' }); next.photos = [photo()]
api.list.mockResolvedValueOnce({ records: [next.summary], total: 1 }); api.detail.mockResolvedValueOnce(next)
wrapper.findComponent(QueryToolbar).vm.$emit('reset', { customerId: '', discardNo: '', inboundCode: '', status: '' }); await flushPromises()
await row(wrapper, 'CD-002').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2')
resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002')
})
it('ignores a detail response after unmounting before it can start photo downloads', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await open(); wrapper.unmount()
const result = detail(); result.photos = [photo()]; resolve(result); await flushPromises()
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps empty evidence explicit and never infers all inventory from missing carton marks', async () => {
const result = detail(); result.items[0]!.cartonMarks = []; result.histories = []; result.summary.status = null
api.list.mockResolvedValueOnce({ records: [summary({ status: null })], total: 1 }); api.detail.mockResolvedValueOnce(result)
const { wrapper, dialog } = await open()
expect(wrapper.text()).not.toContain('undefined'); expect(dialog.text()).not.toContain('undefined')
expect(dialog.text()).toContain('暂无已提交的废弃现场照片'); expect(dialog.text()).toContain('暂无状态流转记录')
expect(dialog.text()).not.toContain('全部库存'); expect(dialog.text()).not.toContain('历史记录')
expect(dialog.findAllComponents(LineClampText).some(item => item.props('content') === '')).toBe(true)
expect(dialog.text()).not.toContain('9007199254740995')
})
it('blocks photo requests with a missing customer scope instead of sending an undefined identifier', async () => {
const result = detail(); result.summary.customerId = null; result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
const { dialog } = await open(); expect(dialog.text()).toContain('照片缺少客户范围')
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps customer and list failures independent and retries each original query', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'discard-customer-request'))
api.list.mockRejectedValueOnce(new ApiResponseError('列表失败', 'A0503', 503, null, 'discard-list-request'))
const wrapper = await page(); expect(wrapper.text()).toContain('discard-customer-request'); expect(wrapper.text()).toContain('discard-list-request')
await retry(errorState(wrapper, '废弃单列表加载失败')); expect(row(wrapper).text()).toContain('CD-001')
await retry(errorState(wrapper, '客户列表加载失败'))
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(api.list).toHaveBeenLastCalledWith({ page: 1, size: 10, customerId: undefined, discardNo: undefined, inboundCode: undefined, status: undefined })
})
it('ignores a stale list response after a newer query has completed', async () => {
let resolve!: (value: { records: CargoDiscardSummary[]; total: number }) => void
api.list.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); api.list.mockResolvedValueOnce({ records: [summary({ discardNo: 'CD-LATEST' })], total: 1 })
wrapper.findComponent(QueryToolbar).vm.$emit('search', { customerId: '', discardNo: 'CD-LATEST', inboundCode: '', status: '' }); await flushPromises()
resolve({ records: [summary({ discardNo: 'CD-OLD' })], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('CD-LATEST'); expect(wrapper.text()).not.toContain('CD-OLD')
})
})
@@ -0,0 +1,172 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DeliveryOrdersView from './DeliveryOrdersView.vue'
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { DeliveryOrderDetail, DeliveryOrderRow, DeliveryReturnRelation } from '../api/deliveryOrders'
import { deliveryDocumentCategories, type DeliveryDocuments, type DeliveryDocumentFile } from '../api/deliveryDocuments'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), returns: vi.fn(), mark: vi.fn(), status: vi.fn(), documents: vi.fn(), save: vi.fn(), upload: vi.fn(), download: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: api.list, getDeliveryOrder: api.detail, listDeliveryReturnInboundOptions: api.returns, markDeliveryReturn: api.mark }))
vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: api.status }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses }))
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: api.documents, saveDeliveryDocuments: api.save, uploadDeliveryDocument: api.upload, downloadDeliveryDocument: api.download }))
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
const row = (): DeliveryOrderRow => ({ version: 0, allowedActions: ['START_DELIVERY', 'REJECT_DELIVERY', 'MARK_RETURN'], id: '100', customerId: 'C1', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 4, signedPackages: null, createdAt: '2026-10-20T10:00:00' })
const detail = (value = row()): DeliveryOrderDetail => ({ order: value, skus: [], fees: [], operations: [] })
const relation = (): DeliveryReturnRelation => ({ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundCode: 'IN-1', customerId: 'C1', returnSources: [
{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 },
{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740996', cartonMark: 'RC-1', palletNo: 'PL-2', skuCode: null, maxReturnPackages: 1 },
] })
const file: DeliveryDocumentFile = { fileId: '9007199254740997', fileName: '完整文件名称.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-10-20T10:00:00' }
const documents = (): DeliveryDocuments => ({ deliveryNo: 'DEL/1', customerId: 'C1', version: 0, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...file }] : [] })) })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
api.warehouses.mockResolvedValue({ records: [] })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [row()], total: 1 }); api.detail.mockResolvedValue(detail()); api.returns.mockResolvedValue([relation()]); api.mark.mockResolvedValue({ exceptionNo: 'EXC-1' }); api.status.mockResolvedValue({ deliveryNo: 'DEL/1' })
api.documents.mockResolvedValue(documents()); api.save.mockResolvedValue(documents()); api.upload.mockResolvedValue({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); api.download.mockResolvedValue(new Blob(['file']))
URL.createObjectURL = vi.fn().mockReturnValue('blob:delivery-file'); URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openPage(action: '编辑状态' | '标记退货') {
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises()
const title = action === '编辑状态' ? '编辑派送状态' : action
return { wrapper, dialog: wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
}
async function openDocuments() { const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DEL/1', customerId: 'C1' }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function validReturn(dialog: VueWrapper) { await field(dialog, '退货原因').get('textarea').setValue(' 客户拒收 '); await dialog.get('.return-source input[type="checkbox"]').setValue(true); dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises() }
describe('native delivery operation dialogs', () => {
it('keeps current facts read-only and submits an allowed status with version zero', async () => {
const { dialog } = await openPage('编辑状态')
expect(dialog.props('title')).toBe('编辑派送状态 · DEL/1')
expect(dialog.get('[aria-label="派送状态依据"]').findAll('input')).toHaveLength(0)
const select = field(dialog, '派送状态').getComponent({ name: 'ElSelect' })
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['DELIVERING', 'REJECTED'])
select.vm.$emit('update:modelValue', 'DELIVERING'); await field(dialog, '备注').get('textarea').setValue(' 本次状态备注 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 0, remark: '本次状态备注' })
})
it('locks status loading and preserves notes through conflict and authoritative reload', async () => {
let resolve!: (value: DeliveryOrderDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { dialog } = await openPage('编辑状态')
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('closeOnPressEscape')).toBe(false)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
resolve(detail()); await flushPromises()
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'REJECTED')
await field(dialog, '备注').get('textarea').setValue('保留状态备注')
api.status.mockRejectedValueOnce(new ApiResponseError('状态冲突', 'A0409', 409, null, 'delivery-status-request'))
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('delivery-status-request')
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
api.detail.mockResolvedValueOnce(detail({ ...row(), version: 8, allowedActions: ['START_DELIVERY'] }))
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('保留状态备注')
field(dialog, '派送状态').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DELIVERING'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenLastCalledWith('DEL/1', 'C1', { status: 'DELIVERING', expectedVersion: 8, remark: '保留状态备注' })
})
it('explains when the latest record has no available status transition', async () => {
api.detail.mockResolvedValueOnce(detail({ ...row(), allowedActions: [] }))
const { dialog } = await openPage('编辑状态')
expect(dialog.text()).toContain('当前状态已无可操作的派送状态')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
})
it('keeps source identities lossless and original return boxes fixed at one package', async () => {
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
expect(dialog.props('fullscreenable')).toBe(true)
expect(dialog.get('.delivery-return-sources th').text()).toBe('操作')
expect(dialog.text()).toContain('RC-1(整箱退回)'); expect(dialog.text()).not.toContain('9007199254740996')
const sources = dialog.findAll('.return-source'); await sources[1]!.get('input[type="checkbox"]').setValue(true); await flushPromises()
expect(sources[1]!.getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ modelValue: 1, disabled: true })
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740993', inventoryPositionId: '9007199254740994', packageCount: 2 }, { outboundPalletItemId: '9007199254740995', returnCartonId: '9007199254740996', packageCount: 1 }], idempotencyKey: expect.any(String) })
})
it('retries failed return source reads within the original delivery and customer', async () => {
api.returns.mockRejectedValueOnce(new ApiResponseError('来源不可用', 'A0503', 503, null, 'return-source-request'))
const { dialog } = await openPage('标记退货'); expect(dialog.text()).toContain('return-source-request')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.mark).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.returns).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.findAll('.return-source')).toHaveLength(2)
})
it('locks duplicate returns and retains failed inputs and the original request', async () => {
let reject!: (cause: Error) => void
api.mark.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openPage('标记退货'); await validReturn(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.mark).toHaveBeenCalledOnce(); expect(dialog.props('confirmLoading')).toBe(true)
expect(field(dialog, '退货原因').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
const original = JSON.parse(JSON.stringify(api.mark.mock.calls[0]![2]))
reject(new ApiResponseError('结果未确认', 'A0503', 503, null, 'return-save-request')); await flushPromises()
expect(dialog.text()).toContain('return-save-request'); expect((field(dialog, '退货原因').get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户拒收 ')
dialog.vm.$emit('confirm'); await flushPromises(); expect(JSON.parse(JSON.stringify(api.mark.mock.calls[1]![2]))).toEqual(original)
})
it('stages real uploads, locks the parent and saves all categories with version zero', async () => {
let resolve!: (value: DeliveryDocumentFile) => void
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await openDocuments()
expect(dialog.get('.document-files th').text()).toBe('操作')
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises()
expect(api.download).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', 'BOL', file.fileId)
await field(dialog, 'BOL号').get('input').setValue(' BOL-NEW ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
const input = dialog.get('[data-category="RECEIPT"] header input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['next'], 'receipt.pdf')] }); await input.trigger('change'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve({ ...file, fileId: '9007199254740998', fileName: 'receipt.pdf' }); await flushPromises()
expect(dialog.get('[aria-label="下载 receipt.pdf"]').attributes('disabled')).toBeDefined()
expect(api.save).not.toHaveBeenCalled(); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.save).toHaveBeenCalledExactlyOnceWith('DEL/1', 'C1', { expectedVersion: 0, bolNo: 'BOL-NEW', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [file.fileId] }, { category: 'RECEIPT', fileIds: ['9007199254740998'] }], idempotencyKey: expect.any(String) })
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps readonly document facts plain and only exposes saved downloads', async () => {
api.documents.mockResolvedValueOnce({ ...documents(), allowedActions: [] })
const { dialog } = await openDocuments()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('当前账号仅可查看已保存资料')
expect(dialog.findAll('input')).toHaveLength(0); expect(dialog.text()).toContain('BOL-1')
expect(dialog.findAll('[data-category]')).toHaveLength(3)
expect(dialog.findAll('button').some(button => button.text() === '上传')).toBe(false)
await dialog.get('[aria-label="下载 完整文件名称.pdf"]').trigger('click'); await flushPromises(); expect(api.download).toHaveBeenCalledOnce()
})
it('shows an unavailable summary after a document read failure and retries within scope', async () => {
api.documents.mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'document-read-request'))
const { dialog } = await openDocuments()
expect(dialog.text()).toContain('document-read-request'); expect(dialog.text()).toContain('资料未返回')
expect(dialog.text()).not.toContain('正在读取…'); expect(dialog.props('showConfirm')).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.documents).toHaveBeenLastCalledWith('DEL/1', 'C1'); expect(dialog.text()).toContain('1/3 已上传')
})
it('retains conflicting document drafts until confirmed reload', async () => {
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
api.save.mockRejectedValueOnce(new ApiResponseError('资料版本冲突', 'A0409', 409, null, 'document-request'))
const { dialog } = await openDocuments(); await field(dialog, 'BOL号').get('input').setValue('保留草稿')
dialog.vm.$emit('confirm'); await flushPromises(); expect(dialog.text()).toContain('document-request')
expect(field(dialog, 'BOL号').get('input').attributes('disabled')).toBeDefined()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(confirmation).toHaveBeenCalledOnce(); expect(api.documents).toHaveBeenCalledOnce()
expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('保留草稿')
confirmation.mockRestore()
api.documents.mockResolvedValueOnce({ ...documents(), version: 1, bolNo: 'BOL-LATEST' })
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
const confirm = document.querySelector<HTMLButtonElement>('.el-message-box__btns .el-button--primary')
expect(confirm).not.toBeNull(); confirm!.click(); await flushPromises()
expect(api.documents).toHaveBeenCalledTimes(2); expect((field(dialog, 'BOL号').get('input').element as HTMLInputElement).value).toBe('BOL-LATEST')
})
})
@@ -56,18 +56,18 @@ describe('DeliveryOrdersView permissions and commands', () => {
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="编辑派送状态"] select').setValue('DELIVERING')
await wrapper.find('[data-dialog^="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100')
await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog="标记退货"] input[type="checkbox"]').setValue(true)
await wrapper.find('[data-dialog="标记退货"] input[type="number"]').setValue(2)
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="标记退货"] textarea').setValue(' 客户拒收 ')
await wrapper.find('[data-dialog^="标记退货"] input[type="checkbox"]').setValue(true)
await wrapper.find('[data-dialog^="标记退货"] input[type="number"]').setValue(2)
await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click')
await flushPromises()
expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', {
lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE',
@@ -83,7 +83,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click')
await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click')
expect(markDeliveryReturn).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -94,16 +94,16 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
const select = wrapper.find('[data-dialog^="编辑派送状态"] select')
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
expect(select.find('option[value="DELIVERED"]').exists()).toBe(false)
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
expect(select.find('option[value="CANCELLED"]').exists()).toBe(false)
await select.setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
await wrapper.find('[data-dialog^="编辑派送状态"] textarea').setValue('123')
let reject!: (error: unknown) => void
vi.mocked(updateDeliveryOrderStatus).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
const button = wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm')
const button = wrapper.find('[data-dialog^="编辑派送状态"] .dialog-confirm')
await button.trigger('click'); await button.trigger('click')
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '123' })
@@ -155,7 +155,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }] }])
const wrapper = mountView(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('原退货箱 RETURN-CARTON-1(整箱退回)')
expect(dialog.text()).toContain('LM-RETURN-1 · 无普通入仓关联')
expect(dialog.text()).not.toContain('9007199254741299')
@@ -175,7 +175,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('LM-RELABEL-1 · 无普通入仓关联')
expect(dialog.text()).not.toContain('9007199254741300')
await dialog.get('textarea').setValue('换标主单拒收')
@@ -198,7 +198,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
const masterSelect = dialog.get('select')
expect(masterSelect.findAll('option').map(option => option.attributes('value'))).toEqual(['109', '110'])
await masterSelect.setValue('109')
@@ -224,7 +224,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises()
const dialog = wrapper.get('[data-dialog="标记退货"]')
const dialog = wrapper.get('[data-dialog^="标记退货"]')
await dialog.get('textarea').setValue('拒收')
const state = wrapper.vm as unknown as { returnItems: Array<typeof ownCarton & { selected: boolean; packageCount: number }> }
state.returnItems = [{ ...ownCarton, returnCartonId: '22', cartonMark: 'FOREIGN-CARTON', selected: true, packageCount: 1 }]
@@ -239,7 +239,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
const dialog = wrapper.find('[data-dialog^="标记退货"]')
expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)')
expect(dialog.text()).not.toContain('9007199254740995')
expect(dialog.text()).not.toContain('可自由填写')
@@ -278,7 +278,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
const wrapper = mountView(); await flushPromises()
try {
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises()
const dialog = wrapper.find('[data-dialog="标记退货"]')
const dialog = wrapper.find('[data-dialog^="标记退货"]')
await dialog.find('select').setValue('109')
await dialog.find('input[type="checkbox"]').setValue(true)
await dialog.find('input[type="number"]').setValue(2)
File diff suppressed because one or more lines are too long
@@ -1,79 +1,107 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDispatch } from '../api/dispatches'
import { ApiResponseError } from '@/shared/api/http'
import { getDispatch, getDispatchEvidence } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue'
import { detail, destination } from '../dispatchTestFixtures'
import { DataTableStub } from './phase5TestStubs'
const sourceQuery = vi.hoisted(() => ({} as Record<string, string>))
const routerPush = vi.hoisted(() => vi.fn())
const canPermission = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query: sourceQuery }), useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
describe('DispatchDetailView', () => {
beforeEach(() => {
routerPush.mockReset()
canPermission.mockReset().mockReturnValue(false)
Object.keys(sourceQuery).forEach(key => delete sourceQuery[key])
vi.mocked(getDispatch).mockReset().mockResolvedValue({
id: '1', dispatchNo: 'PC/1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
})
})
it('loads the business dispatch number and renders summary and related orders', async () => {
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
const query = vi.hoisted(() => ({} as Record<string, string>))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query }), useRouter: () => ({ push }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchEvidence: vi.fn() }))
vi.mock('@/modules/overseas-master-data', async () => ({
vehicleStatusLabels: (await vi.importActual<typeof import('../../overseas-master-data/presentation')>('../../overseas-master-data/presentation')).vehicleStatusLabels,
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
it('uses only four accessible colored tabs and switches without another network read', async () => {
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
expect(wrapper.text()).toContain('PC/1')
expect(wrapper.text()).toContain('CA-100')
expect(wrapper.text()).toContain('待发车')
const table = wrapper.findComponent(DataTableStub)
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map(tab => tab.text())).toEqual(['🚚车辆信息', '📋排车信息', '📝单据信息', '📦关联订单明细'])
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
})
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` })
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
})
it.each([
['IN_SERVICE', '在役'], ['DISPATCHED', '外派中'], ['MAINTENANCE', '维修中'], ['INACTIVE', '停用'], ['RETURNED', '已归还'],
['FUTURE_STATUS', '—'], ['toString', '—'], [null, '—'],
])('presents vehicle status %s through the existing master-data labels or an explicit placeholder', async (status, label) => {
const value = detail(); value.vehicleInfo.vehicle!.vehicleStatus = status
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const field = wrapper.findAll('.detail-fields > div').find(row => row.find('dt').text() === '车辆状态')!
expect(field.get('dd').text()).toBe(label)
if (status) expect(field.text()).not.toContain(status)
wrapper.unmount()
})
it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', appointmentBatches: [], status: 'PENDING_DEPARTURE', items: [
{ id: '11', planPalletId: '31', orderNo: 'LM-1', warehousePalletId: '21', grossWeightKg: 88.5, volumeCbm: 1 },
{ id: '12', planPalletId: '32', orderNo: 'LM-1', warehousePalletId: '22', grossWeightKg: null, volumeWeightKg: 999, volumeCbm: 1 },
] })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
const value = detail(); value.documents.deliveries = ['DEL-A', 'DEL-B'].map((deliveryNo, index) => ({ deliveryNo, customerId: `C${index}`, customerName: `客户${index}`, outboundNo: `OUT-${index}`, groupKey: `group-${index}`, destination: destination(), status: 'WAIT_DELIVERY', packageCount: 1, palletCount: 1 }))
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
expect(wrapper.text()).toContain('DEL-A'); expect(wrapper.text()).toContain('DEL-B'); expect(wrapper.text()).toContain('真实备注')
expect(wrapper.findAll('button').some(button => button.text() === 'DEL-A')).toBe(false); wrapper.unmount()
})
it('requires a literal service allowance and permission for a write action', async () => {
can.mockReturnValue(true); vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { DEPART: { allowed: false, blockCode: 'NOT_ARRANGED', blockReason: '尚未安排派送' } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
})
it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'AFTER_LOADING', actions: {
ARRANGE_DELIVERY: { allowed: true, blockCode: null, blockReason: null },
EDIT: { allowed: false, blockCode: 'STAGE', blockReason: '当前阶段或作业事实不满足操作条件' },
PICK: { allowed: false, blockCode: 'PERMISSION', blockReason: '当前无此操作权限' },
} }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('装车后')
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
expect(wrapper.text()).not.toContain('当前无此操作权限')
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === '安排派送')!.trigger('click')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'ARRANGE_DELIVERY' })
wrapper.unmount()
})
it('shows server blocking reasons and opens only authorized original documents', async () => {
const data = await getDispatch('PC/1')
data.actions = { canEdit: false, editBlockReason: '请先解除车次关联', canCancel: false, cancelBlockReason: '请先解除车次关联', canRemovePallet: false, removePalletBlockReason: '请先解除车次关联', canTransfer: false, transferBlockReason: '原出库已取消,请取消原排车后重新预排', outboundNo: 'OUT-1', outboundStatus: 'CANCELLED' }
data.workflow = { tripNo: 'TR-1', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
vi.mocked(getDispatch).mockResolvedValue(data)
canPermission.mockReturnValue(true)
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
expect(wrapper.text()).toContain('原出库已取消')
await wrapper.findAll('button').find(button => button.text().includes('处理原车次'))!.trigger('click')
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-1' }, query: { tab: 'overview' } })
await wrapper.findAll('button').find(button => button.text().includes('查看原出库'))!.trigger('click')
expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-1' } })
wrapper.unmount()
canPermission.mockReturnValue(false)
const readonly = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
expect(readonly.findAll('button').some(button => ['处理原车次', '查看原出库'].some(label => button.text().includes(label)))).toBe(false)
expect(readonly.text()).toContain('原出库已取消')
readonly.unmount()
})
it.each([false, true])('returns to the original inquiry only for a valid source: %s', async fromInquiry => {
if (fromInquiry) Object.assign(sourceQuery, { fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
const backButton = wrapper.get('button.density-back')
expect(backButton.text()).toBe(fromInquiry ? '返回原拼车登记' : '返回列表')
await backButton.trigger('click')
expect(routerPush).toHaveBeenCalledWith(fromInquiry ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } } : { name: 'overseas-dispatches' })
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
vi.mocked(getDispatch).mockResolvedValueOnce(value)
vi.mocked(getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('附件不存在', 'A0404', 404, undefined, 'evidence-request', 'evidence-trace'))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
expect(wrapper.text()).toContain('本趟附件操作未完成')
expect(wrapper.text()).toContain('A0404')
expect(wrapper.text()).toContain('evidence-request')
expect(wrapper.text()).toContain('evidence-trace')
expect(value.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['301'])
wrapper.unmount()
})
it('retains a real API failure and exposes retry rather than rendering a successful empty detail', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('permission denied'))
const wrapper = mount(DispatchDetailView, options); await flushPromises(); expect(wrapper.text()).toContain('排车详情加载失败,请重试'); expect(wrapper.findAll('[role="tab"]')).toHaveLength(0); wrapper.unmount()
})
it('keeps the original inquiry return target', async () => {
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
})
@@ -1,187 +1,98 @@
<script setup lang="ts">
import { inquiryReturnTarget } from '@/modules/overseas-master-data'
import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { getDispatch } from '../api/dispatches'
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number | null
palletCount: number
fbaWarehouse: string
volume: number
containerNo: string
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionBlockReasons = computed(() => {
const actions = detail.value?.actions
if (!actions) return detail.value ? ['操作资格暂未返回,请刷新详情'] : []
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
const detailActions: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
const vehicleStatus = computed(() => {
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
})
function openTrip() {
if (!detail.value?.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.value.workflow.tripNo }, query: { tab: 'overview' } })
}
function openOutbound() {
if (!detail.value?.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: detail.value.actions.outboundNo } })
}
const dispatchStatusLabels: Record<string, string> = {
PENDING_DEPARTURE: '待发车',
PICKING: '拣货中',
READY_TO_DEPART: '待发车(拣货完成)',
PARTIAL_READY_TO_DEPART: '待发车(部分完成)',
DEPARTED: '已发车',
PARTIAL_DEPARTED: '已发车(部分完成)',
CANCELLED: '已取消',
}
const appointmentStatusLabels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
RESERVED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function dispatchStatus(value: unknown) {
const code = String(value ?? '').trim()
return dispatchStatusLabels[code] || text(code)
}
function appointmentStatus(value: unknown) {
const code = String(value ?? '').trim()
return appointmentStatusLabels[code] || text(code)
}
function orderReferences(item: DispatchItemDto) {
const references = String(item.orderRefs || '').split(/[,,;;\s]+/).map((value) => value.trim()).filter(Boolean)
return references.length ? references : [text(item.orderNo)]
}
const vehicleFields = computed<DetailField[]>(() => [
{ label: '车牌号', value: text(detail.value?.vehiclePlate) },
{ label: '司机', value: text(detail.value?.driverName) },
{ label: '联系电话', value: text(detail.value?.driverPhone) },
{ label: '拖车公司名称', value: text(detail.value?.carrierName) },
{ label: '车辆状态', value: dispatchStatus(detail.value?.status) },
const vehicleFields = computed(() => [
['车牌号', text(detail.value?.vehicleInfo.vehicle?.plate)], ['司机', text(detail.value?.vehicleInfo.driver?.name)],
['联系电话', text(detail.value?.vehicleInfo.driver?.phone)], ['拖车公司名称', text(detail.value?.vehicleInfo.vehicle?.carrierName)], ['车辆状态', vehicleStatus.value],
])
const dispatchFields = computed<DetailField[]>(() => [
{ label: '路线', value: text(detail.value?.route) },
{ label: '发车时间', value: formatOverseasDateTime(detail.value?.departAt) },
{ label: '预计到达时间', value: formatOverseasDateTime(detail.value?.estimatedArrivalAt) },
{ label: 'FBA预约', value: appointmentStatus(detail.value?.fbaAppointmentStatus) },
{ label: '车辆预约', value: appointmentStatus(detail.value?.vehicleAppointmentStatus) },
const dispatchFields = computed(() => [
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
])
const documentFields = computed<DetailField[]>(() => [
{ label: '排车单号', value: text(detail.value?.dispatchNo || dispatchNo.value) },
{ label: '关联订单', value: text(detail.value?.orderNos) },
{ label: '创建时间', value: formatOverseasDateTime(detail.value?.createdAt) },
{ label: '更新时间', value: '—' },
{ label: '备注', value: '—' },
...(detail.value?.partialCompletionReason
? [{ label: '部分完成原因', value: text(detail.value.partialCompletionReason) }]
: []),
const documentFields = computed(() => [
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
['更新时间', time(detail.value?.documents.updatedAt)], ['备注', text(detail.value?.documents.remark)],
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number | null; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
containerNo: '—',
}))
})
const relatedOrders = computed(() => detail.value?.orderDetails || [])
const orderColumns: DataTableColumn[] = [
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'weight', prop: 'weight', label: '重量(kg)', minWidth: 130, align: 'right' },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volume', label: '体积(m³)', minWidth: 130, align: 'right' },
{ key: 'fbaWarehouse', prop: 'destinationFbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volumeCbm', label: '体积(m³)', minWidth: 130, align: 'right' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 170 },
]
let generation = 0
async function load() {
if (!dispatchNo.value) {
loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }
return
}
const current = ++generation, no = dispatchNo.value
detail.value = null; loadError.value = null
if (!no) { loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }; return }
loading.value = true
loadError.value = null
try { detail.value = await getDispatch(dispatchNo.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { loading.value = false }
try { const result = await getDispatch(no); if (current === generation) { if (result.dispatchNo !== no) throw new Error('返回排车单与当前业务号不一致'); detail.value = result } }
catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { if (current === generation) loading.value = false }
}
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
onMounted(load)
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
watch(dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<section v-if="detail" class="detail-section" aria-label="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><div class="density-actions"><el-button v-if="detail.workflow && access.can('platform:fleet:trip:list')" class="density-action" @click="openTrip">处理原车次 {{ detail.workflow.tripNo }}</el-button><el-button v-if="detail.actions?.outboundNo && access.can(permission.outboundList)" class="density-action" @click="openOutbound">查看原出库 {{ detail.actions.outboundNo }}</el-button></div></section>
<div class="summary-grid">
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
</el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></el-tab-pane>
</el-tabs>
</div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="load" />
</OverseasDetailShell>
</template>
<style scoped>
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: 1 / -1; }
.order-section h2 { margin-right: var(--admin-space-1); margin-left: var(--admin-space-1); }
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; }
.detail-fields, .document-section .detail-fields { grid-template-columns: 1fr; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
}
.dispatch-detail { min-width: 0; min-height: 0; }
.summary-grid { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-bottom: 0; background: #fff; }
.summary-grid > .detail-section { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
.order-section { overflow: hidden; border: 0; border-top: 0; background: #fff; }
.linked-documents { grid-column: 1 / -1; }
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
</style>
@@ -1,382 +1,71 @@
import { createPinia } from 'pinia'
import ElementPlus, { ElMessage, ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchesView from './DispatchesView.vue'
vi.mock('@/modules/warehouses', () => ({
listAllPlatformWarehouses: vi.fn(async (query: { keyword: string }) => [{ id: query.keyword, warehouseCode: query.keyword, warehouseType: 'FBA' }]),
getPlatformWarehouse: vi.fn(async (id: string) => ({ warehouse: { warehouseCode: id, warehouseType: id === '321' ? 'OVERSEAS' : 'FBA' }, addresses: [{ addressType: 'OPERATING', addressLine1: '100 Example Street', city: 'City', stateProvince: 'CA', countryCode: 'US' }], capabilities: [] })),
}))
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { detail, listRow, page } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) }))
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function memoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
enableAutoUnmount(afterEach)
afterEach(() => vi.restoreAllMocks())
afterAll(() => vi.unstubAllGlobals())
const allowedActions = { canEdit: true, editBlockReason: null, canCancel: true, cancelBlockReason: null,
canRemovePallet: true, removePalletBlockReason: null, canTransfer: true, transferBlockReason: null, outboundNo: null, outboundStatus: null }
describe('DispatchesView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listDispatches).mockReset().mockResolvedValue({
records: [{
id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], actions: allowedActions, vehiclePlate: 'CA-100', driverName: '张师傅',
driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9',
overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
status: 'PENDING_DEPARTURE', palletCount: 12, packageCount: 80, createdAt: '2026-08-19T16:00:00',
}],
page: 1, size: 20, total: 1, pages: 1,
})
vi.mocked(updateDispatch).mockReset()
vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-1', outboundStatus: 'WAIT_OUTBOUND', palletCount: 12, packageCount: 80, deliveries: [], replayed: false })
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
canPermission.mockReset().mockReturnValue(true)
})
it('queries stages on the server and discards an older response after switching', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof listDispatches>>) => void
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click')
await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined }))
expect(wrapper.text()).toContain('PC-1')
resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 })
await flushPromises()
expect(wrapper.text()).toContain('PC-1')
await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click')
await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 }))
})
it('shows the shared route map only in the after-loading vehicle section without another query', async () => {
const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('查看路线')
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click')
await flushPromises()
const requests = vi.mocked(listDispatches).mock.calls.length
await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('origin')).toEqual({ kind: 'warehouse', label: '洛杉矶仓', warehouseId: '321' })
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('destinations')).toEqual(['ONT8', 'LAX9'].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })))
expect(listDispatches).toHaveBeenCalledTimes(requests)
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
await flushPromises()
await wrapper.get('[aria-label="排车业务分区"]').findAll('button')[1]!.trigger('click')
expect(wrapper.findAll('button').some(button => button.text() === '查看路线')).toBe(false)
})
it('shows review facts and opens the matching trip section with read permission', async () => {
const result = await listDispatches({ page: 1, size: 10 })
result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
canPermission.mockImplementation(code => code === 'platform:fleet:trip:list')
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const sections = wrapper.get('[aria-label="排车业务分区"]')
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
await flushPromises()
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2)
expect(wrapper.text()).not.toContain('请核对货量')
await sections.findAll('button')[1]!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('请核对货量')
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click')
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
await sections.findAll('button')[2]!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('请核对货量')
expect(wrapper.text()).toContain('待审核')
const links = wrapper.findAll('button').filter(button => button.text() === '查看审核')
await links[0]!.trigger('click')
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } })
expect(wrapper.text()).not.toContain('前往审核')
expect(listDispatches).toHaveBeenCalledTimes(2)
})
it.each([0, 2, 3, 4].flatMap(stageIndex => [1, 2].map(sectionIndex => [stageIndex, sectionIndex])))('leaving a review section for stage %s resets section %s to vehicle information', async (stageIndex, sectionIndex) => {
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const stages = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
const sections = wrapper.get('[aria-label="排车业务分区"]')
await stages[1]!.trigger('click')
await flushPromises()
await sections.findAll('button')[sectionIndex]!.trigger('click')
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
await stages[stageIndex]!.trigger('click')
await flushPromises()
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
expect(sections.get('button').attributes('aria-selected')).toBe('true')
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(true)
expect(wrapper.findAll('button').some(button => ['前往审核', '查看审核'].includes(button.text()))).toBe(false)
await stages[1]!.trigger('click')
await flushPromises()
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(sections.get('button').attributes('aria-selected')).toBe('true')
})
it.each([
[[], ['查看', '打印BOL'], false],
[['edit'], ['查看', '打印BOL', '编辑'], false],
[['cancel'], ['查看', '打印BOL', '取消'], false],
[['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
[['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
[['edit', 'cancel'], ['查看', '打印BOL', '编辑', '更多'], true],
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
})
it('keeps the integrated header and filters usable through failure and retry', async () => {
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('网络错误'))
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
const card = wrapper.get('.data-table--integrated')
expect(card.get('.data-table__toolbar h1').text()).toBe('排车列表')
expect(card.get('.data-table__toolbar button[aria-label="列设置"]').text()).toBe('字段设置')
expect(card.get('.data-table__filters').findComponent(QueryToolbar).exists()).toBe(true)
expect(card.find('.el-table').exists()).toBe(false)
expect(card.find('.data-table__pagination').exists()).toBe(false)
wrapper.findComponent(LoadErrorState).vm.$emit('retry')
await flushPromises()
expect(card.find('.data-table__state').exists()).toBe(false)
expect(card.get('.el-table').text()).toContain('PC-1')
expect(card.get('.data-table__pagination').text()).toContain('共 1 条排车记录')
})
it('loads immediately and sends only supported page filters', async () => {
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 10, dispatchNo: undefined, vehiclePlate: undefined, status: undefined, fbaAppointmentNo: undefined })
const model: QueryModel = {
dispatchNo: 'PC-1', vehiclePlate: 'CA-100',
status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
}
wrapper.findComponent(QueryToolbar).vm.$emit('search', model)
await flushPromises()
expect(listDispatches).toHaveBeenCalledWith({
page: 1, size: 10, dispatchNo: 'PC-1',
vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
})
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('待发车')
expect(wrapper.text()).toContain('FBA-1 · 已确认')
expect(wrapper.text()).toContain('已预约')
expect(wrapper.get('.data-table').classes()).not.toContain('admin-text-actions')
expect(wrapper.get('.query-toolbar').classes()).not.toContain('admin-text-actions')
expect(wrapper.get('.data-table__toolbar button[aria-label="列设置"]').find('svg').exists()).toBe(true)
expect(wrapper.get('.query-toolbar__actions .el-button--primary').text()).toBe('查询')
expect(wrapper.get('button[aria-label="上一页"]').find('svg').exists()).toBe(true)
expect(wrapper.findAll('.query-toolbar__actions button svg')).toHaveLength(3)
expect(wrapper.get('.el-table__body-wrapper .row-actions').classes()).toContain('admin-text-actions')
})
it('keeps long dispatch identifiers as strings', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce({
records: [{ id: '190000000000000099', dispatchNo: '190000000000000099', appointmentBatches: [] }],
page: 1, size: 20, total: 1, pages: 1,
})
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.html()).toContain('190000000000000099')
})
it('opens the server-generated BOL PDF with one request', async () => {
const pdfWindow = { location: { href: '' }, close: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(pdfWindow as unknown as Window)
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:dispatch-bol'), revokeObjectURL: vi.fn() })
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(getDispatchBolPdf).not.toHaveBeenCalled()
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text().trim() === '打印BOL')!.trigger('click')
await flushPromises()
expect(getDispatchBolPdf).toHaveBeenCalledOnce()
expect(getDispatchBolPdf).toHaveBeenCalledWith('PC-1')
expect(pdfWindow.location.href).toBe('blob:dispatch-bol')
})
it('documents the backend feature flag required by a missing endpoint', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('uses server blocks even for a pending row and offers the original trip and outbound paths', async () => {
const result = await listDispatches({ page: 1, size: 10 })
Object.assign(result.records[0]!, {
actions: { ...allowedActions, canEdit: false, canCancel: false, canRemovePallet: false, canTransfer: false,
editBlockReason: '请先在车次 TR-A 中解除关联', cancelBlockReason: '请先在车次 TR-A 中解除关联',
removePalletBlockReason: '请先在车次 TR-A 中解除关联', transferBlockReason: '原出库已取消,请取消原排车后通过新预排重新安排',
outboundNo: 'OUT-A', outboundStatus: 'CANCELLED' },
workflow: { tripNo: 'TR-A', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } },
})
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(item => item.element.closest('.el-table__body-wrapper'))!
const visible = actions.props('actions').filter(action => action.visible !== false).map(action => action.key)
expect(visible.filter(key => ['edit', 'cancel', 'transfer'].includes(key))).toEqual([])
expect(visible).toEqual(expect.arrayContaining(['trip', 'outbound', 'detail']))
expect(wrapper.text()).toContain('原出库已取消')
actions.vm.$emit('action', 'edit'); actions.vm.$emit('action', 'transfer'); await flushPromises()
expect(wrapper.findAllComponents(AppDialog).filter(dialog => ['编辑排车', '转派送'].includes(String(dialog.props('title')))).every(dialog => !dialog.props('modelValue'))).toBe(true)
actions.vm.$emit('action', 'trip'); await flushPromises()
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
actions.vm.$emit('action', 'outbound'); await flushPromises()
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-A' } })
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
})
it('keeps write commands closed when action capabilities are missing', async () => {
const result = await listDispatches({ page: 1, size: 10 })
delete result.records[0]!.actions
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
expect(wrapper.findComponent(RowActions).props('actions').filter(action => action.visible !== false).map(action => action.key)).toEqual(['detail', 'bol'])
expect(wrapper.text()).toContain('操作资格暂未返回,请刷新列表')
})
it.each([
{ replayed: true, outboundStatus: 'WAIT_OUTBOUND', method: 'info' as const, message: '本次未重复生成' },
{ replayed: true, outboundStatus: 'CANCELLED', method: 'warning' as const, message: '请取消原排车后重新预排' },
])('reports transfer replay accurately for $outboundStatus', async result => {
const success = vi.spyOn(ElMessage, 'success'), notice = vi.spyOn(ElMessage, result.method)
vi.mocked(transferDispatchToDelivery).mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-OLD', palletCount: 1, packageCount: 2, deliveries: [], replayed: result.replayed, outboundStatus: result.outboundStatus })
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [] })
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
expect(notice).toHaveBeenCalledWith(expect.stringContaining(result.message))
expect(success).not.toHaveBeenCalledWith('已生成出库单和派送单')
})
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
] })
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchExternalFreight).toHaveBeenCalledOnce()
expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
expect(dialog.text()).toContain('FB-1')
expect(dialog.text()).not.toContain('请输入派送费用')
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' }))
})
it('confirms an original delivery fee without submitting a new receivable', async () => {
vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo',
cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
})
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true)
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
expect(dialog.text()).toContain('本次全部货物已覆盖:派送')
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
})
it('gates edit, cancel, pallet removal and transfer with independent permissions and server capabilities', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canEdit === true')
expect(source).toContain('access.can(permission.dispatchCancel) && row.actions?.canCancel === true')
expect(source).toContain('access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true')
expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true')
expect(source).toContain('!canRemovePallet(editRow.value)')
expect(source).toContain('removeDispatchItem')
expect(source).not.toContain('托盘箱唛确认')
expect(source).not.toContain('palletCartonMarks')
expect(source).toContain('v-model="editForm.route" readonly')
expect(source).not.toContain('route: editForm.route.trim()')
expect(source).toContain('dispatchVehicleSizeOptions')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('转派送')
})
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '<div />' }, routeLocation: (label: string) => ({ label }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.text()).toContain('PC-OTHER 的确认发车结果尚待核对')
await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
const dialog = wrapper.get('dispatch-workflow-dialog-stub')
expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
})
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
await tabs[3]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
})
it('queries each review section on the server and includes only its legal review filter', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'DISPATCH_REVIEW' }))
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'PENDING', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ reviewStatus: 'PENDING' })); wrapper.unmount()
})
it('discards a late page response after switching phase', async () => {
let old!: (value: DispatchPage) => void
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { old = resolve }))
const wrapper = mount(DispatchesView, options)
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
old(page([])); await flushPromises(); expect(wrapper.text()).toContain('PC/1'); wrapper.unmount()
})
it('gates every mutation with both its literal service capability and independent permission', async () => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { DEPART: { allowed: false, blockReason: '未安排', blockCode: 'NO' }, EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
can.mockReturnValue(false); wrapper.unmount()
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail', 'bol']); readonly.unmount()
})
it('keeps server errors visible with the integrated query and retry context', async () => {
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
const wrapper = mount(DispatchesView, options); await flushPromises(); expect(wrapper.text()).toContain('排车列表加载失败,请重试'); expect(wrapper.findComponent(QueryToolbar).exists()).toBe(true); wrapper.unmount()
})
it('navigates by the business number without arranging a separate trip', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
it('reads one BOL on demand and preserves popup failure handling', async () => {
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -1,743 +1,127 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { appointmentReturnTarget, appointmentReturnQuery } from '@/modules/appointment-calendar'
import { TripPreparationDialog, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto, type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices, coveredServiceLabels, type FeeCoveragePreview, type FeeCoverageSelection } from '../feeCoverageTypes'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter()
const routeOpen = ref(false)
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
function openRoute(row: DispatchListItem) {
routeOrigin.value = { kind: 'warehouse', label: row.overseasWarehouse, warehouseId: dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId || undefined }
// The service's route field is a comma-separated set of destination warehouse codes, not a stop sequence.
routeDestinations.value = [...new Set(row.route.split(',').map(code => code.trim()).filter(Boolean))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code }))
routeOpen.value = true
}
const access = useAccessPreviewStore()
const rows = ref<DispatchListItem[]>([])
const selectedDispatches = ref<DispatchListItem[]>([]), prepareOpen = ref(false), prepareWarehouse = ref(''), prepareWarehouseName = ref(''), prepareNumbers = ref<string[]>([])
const canPrepare = computed(() => access.can('platform:fleet:trip:edit') && access.can('platform:fleet:trip:list'))
function beginPreparation() {
const selected = selectedDispatches.value
if (!selected.length || selected.some(row => row.statusCode !== 'PENDING_DEPARTURE' || row.workflow)) { ElMessage.warning('请选择尚未关联车次的待发运排车单'); return }
const warehouses = new Set(selected.map(row => dispatchSnapshots.get(row.dispatchNo)?.overseasWarehouseId))
if (warehouses.size !== 1 || ![...warehouses][0]) { ElMessage.warning('请选择同一起运仓的排车单'); return }
prepareWarehouse.value = String([...warehouses][0]); prepareWarehouseName.value = selected[0]!.overseasWarehouse
prepareNumbers.value = selected.map(row => row.dispatchNo); prepareOpen.value = true
}
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<'' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'>('')
const stages = [
{ value: '', label: '全部' }, { value: 'BEFORE_LOADING', label: '装车前' },
{ value: 'LOADING', label: '装车中' }, { value: 'AFTER_LOADING', label: '装车后' },
{ value: 'CANCELLED', label: '已取消' },
] as const
const section = ref<'vehicle' | 'dispatchReview' | 'loadingReview'>('vehicle')
const sections = [
{ value: 'vehicle', label: '车辆信息' },
{ value: 'dispatchReview', label: '调度审核' },
{ value: 'loadingReview', label: '理货员审核' },
] as const
const visibleSections = computed(() => stage.value === 'BEFORE_LOADING' ? sections : sections.filter(item => item.value === 'vehicle'))
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
let loadSequence = 0
function switchStage(value: typeof stage.value) {
if (stage.value === value) return
stage.value = value
if (value !== 'BEFORE_LOADING') section.value = 'vehicle'
filters.value.status = ''
page.value = 1
void load()
}
function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') {
if (!row.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab, ...appointmentReturnQuery(route.query) } })
}
const filters = ref<QueryModel>({
dispatchNo: typeof route.query.dispatchNo === 'string' ? route.query.dispatchNo : '',
vehiclePlate: '',
status: '',
fbaAppointmentNo: '',
})
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const transferIdempotencyKey = ref('')
const externalFreight = ref<ExternalFreightPreview | null>(null)
const coveragePreview = ref<FeeCoveragePreview | null>(null)
const coverageSelection = ref<FeeCoverageSelection[]>([])
const coveredServices = computed(() => fullyCoveredServices(coveragePreview.value, coverageSelection.value, externalFreight.value?.quotes.map(row => row.incomingNo) || []))
const requiresDeliveryFee = computed(() => !!externalFreight.value?.requiresDeliveryFee && !coveredServices.value.includes('DELIVERY_FEE'))
const requiresLoadingFee = computed(() => needsLoadingFee.value && !coveredServices.value.includes('LOADING_FEE'))
const requiresAppointmentFee = computed(() => isBuyAppointment.value && !coveredServices.value.includes('APPOINTMENT_FEE'))
const freightLoading = ref(false)
let freightGeneration = 0
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
const editSaving = ref(false)
const editItems = ref<DispatchItemDto[]>([])
const removingItemId = ref('')
const bolLoadingNo = ref('')
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({
vehiclePlate: '',
driverName: '',
driverPhone: '',
carrierName: '',
route: '',
departAt: '',
fbaAppointmentNo: '',
fbaAppointmentStatus: '',
vehicleAppointmentStatus: '',
estimatedArrivalAt: '',
})
const transferForm = reactive({
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: '',
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: '',
driverPhone: '',
vehiclePlate: '',
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined as number | undefined,
loadingFee: undefined as number | undefined,
appointmentFee: undefined as number | undefined,
remark: '',
})
const deliveryMethodOptions = [
{ label: 'FBA自提', value: 'FBA_PICKUP' },
{ label: '客户自提', value: 'CUSTOMER_PICKUP' },
{ label: '卡派打托', value: 'TRUCK_PALLET' },
{ label: '卡派地板', value: 'TRUCK_FLOOR' },
{ label: '快递', value: 'EXPRESS' },
{ label: '外配', value: 'OUTSOURCED' },
{ label: '专车直送', value: 'DEDICATED_TRUCK' },
{ label: '零担派送', value: 'LTL' },
]
const pickupMethodOptions = [
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' },
{ label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' },
{ label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
]
const loadingTypeOptions = [
{ label: '托盘', value: 'PALLET' },
{ label: '地板', value: 'FLOOR' },
{ label: '混装', value: 'MIXED' },
]
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = dispatchVehicleSizeOptions
const appointmentAccountOptions = [
{ label: '4号账号(美西)', value: '4' },
{ label: '8号账号(美东)', value: '8' },
{ label: '10号账号(美中)', value: '10' },
{ label: '12号账号(加拿大)', value: '12' },
{ label: '买约', value: 'BUY_APPOINTMENT' },
]
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
const statusOptions = [
{ label: '待发车', value: 'PENDING_DEPARTURE' },
{ label: '拣货中', value: 'PICKING' },
{ label: '待发车(拣货完成)', value: 'READY_TO_DEPART' },
{ label: '待发车(部分完成)', value: 'PARTIAL_READY_TO_DEPART' },
{ label: '已发车', value: 'DEPARTED' },
{ label: '已发车(部分完成)', value: 'PARTIAL_DEPARTED' },
{ label: '已取消', value: 'CANCELLED' },
]
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE', reviewStatus: '' })
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
const fields = computed<QueryField[]>(() => [
{
key: 'dispatchNo',
type: 'input',
label: '排车单号',
placeholder: '请输入排车单号',
width: 220,
},
{
key: 'vehiclePlate',
type: 'input',
label: '车牌号',
placeholder: '请输入车牌号',
width: 180,
},
{
key: 'status',
type: 'select',
label: '车辆状态',
placeholder: '全部状态',
options: statusOptions,
width: 180,
},
{
key: 'fbaAppointmentNo',
type: 'input',
label: 'FBA预约号',
placeholder: '请输入FBA预约号',
width: 220,
},
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'reviewStatus', label: '审核结果', type: 'select' as const, width: 140, options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })) }] : []),
])
const columns: DataTableColumn[] = [
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 220, fixed: 'left', singleLine: false },
{ key: 'workflow', label: '关联车次', minWidth: 230, slotName: 'workflow', singleLine: false },
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 150, singleLine: false },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 150, singleLine: false },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
{
key: 'carrierName',
prop: 'carrierName',
label: '托车公司名称',
minWidth: 160,
},
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 260, singleLine: false },
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
{
key: 'overseasWarehouse',
prop: 'overseasWarehouse',
label: '海外仓',
minWidth: 160,
},
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
{
key: 'fbaAppointment',
prop: 'fbaAppointment',
label: 'FBA预约',
minWidth: 180,
},
{
key: 'vehicleAppointment',
prop: 'vehicleAppointment',
label: '车辆预约',
minWidth: 130,
},
{
key: 'estimatedArrivalAt',
prop: 'estimatedArrivalAt',
label: '预计到达时间',
minWidth: 170,
},
{
key: 'palletCount',
prop: 'palletCount',
label: '托盘数',
width: 90,
align: 'right',
},
{
key: 'packageCount',
prop: 'packageCount',
label: '箱数',
width: 80,
align: 'right',
},
{
key: 'status',
prop: 'status',
label: '车辆状态',
width: 150,
slotName: 'status',
},
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
const visibleColumns = computed(() => {
if (section.value === 'vehicle') {
const visible = columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
if (stage.value === 'AFTER_LOADING') visible.splice(visible.findIndex(column => column.key === 'status'), 0,
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
return visible
}
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
return keys.map(key => columns.find(column => column.key === key)!)
})
const preparationColumns = computed<DataTableColumn[]>(() => canPrepare.value ? [visibleColumns.value[0]!, { key: 'selection', type: 'selection', width: 45, selectable: (row: DispatchListItem) => row.statusCode === 'PENDING_DEPARTURE' && !row.workflow }, ...visibleColumns.value.slice(1)] : visibleColumns.value)
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
function text(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || '—'
}
function number(value: unknown) {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : 0
}
function statusLabel(value: unknown) {
const status = String(value || '')
return statusOptions.find((option) => option.value === status)?.label || text(value)
}
function appointmentStatusLabel(value: unknown) {
const code = String(value || '')
const labels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
return labels[code] || text(value)
}
function appointmentLabel(numberValue: unknown, statusValue: unknown) {
const appointmentNo = text(numberValue)
const appointmentStatus = appointmentStatusLabel(statusValue)
if (appointmentNo === '—') return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : `${appointmentNo} · ${appointmentStatus}`
}
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
return {
actions: dto.actions,
workflow: dto.workflow,
id: String(dto.id),
dispatchNo: text(dto.dispatchNo),
vehiclePlate: text(dto.vehiclePlate),
driverName: text(dto.driverName),
driverPhone: text(dto.driverPhone),
carrierName: text(dto.carrierName),
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: appointmentStatusLabel(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
createdAt: formatOverseasDateTime(dto.createdAt),
}
}
function dispatchError(error: unknown) {
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
return details.code === 'HTTP_404'
? {
...details,
message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布',
}
: details
}
async function load(model: QueryModel = filters.value) {
const sequence = ++loadSequence
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
...(stage.value === 'AFTER_LOADING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0
async function load() {
const current = ++sequence
loading.value = true; loadError.value = null
try {
const result = await listDispatches({
page: page.value,
size: pageSize.value,
stage: stage.value || undefined,
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
})
if (sequence !== loadSequence) return
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = dispatchError(error)
} finally {
if (sequence === loadSequence) loading.value = false
}
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value, ...(section.value !== 'VEHICLE_INFO' && model.value.reviewStatus ? { reviewStatus: String(model.value.reviewStatus) as ReviewStatus } : {}) } : {}) })
if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false }
}
function search(model: QueryModel) {
page.value = 1
void load(model)
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; model.value.reviewStatus = ''; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { section.value = value; model.value.reviewStatus = ''; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
}
function reset(model: QueryModel) {
page.value = 1
void load(model)
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (value === 'bol') return void openBol(row)
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
operation.value = undefined
const requested = value as DispatchAction
if (!canDispatchAction(row.actions, requested, access.can)) return
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
}
function openDetail(row: DispatchListItem) {
void router.push({
name: 'overseas-dispatch-detail',
params: { dispatchNo: row.dispatchNo },
})
function recoverPending() {
const command = recovery.pending.value
if (!command) return
if (command.action === 'CREATE') { createOpen.value = true; return }
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
}
async function openBol(row: DispatchListItem) {
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value) return
const pdfWindow = window.open('', '_blank')
if (!pdfWindow) { ElMessage.warning('浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口'); return }
const windowHandle = window.open('', '_blank')
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
bolLoadingNo.value = row.dispatchNo
mutationError.value = null
try {
const pdf = await getDispatchBolPdf(row.dispatchNo)
const url = URL.createObjectURL(pdf)
pdfWindow.location.href = url
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
pdfWindow.close()
mutationError.value = getApiErrorDetails(cause, 'BOL PDF 生成失败,请重试')
} finally {
bolLoadingNo.value = ''
}
try { const pdf = await getDispatchBolPdf(row.dispatchNo); const url = URL.createObjectURL(pdf); windowHandle.location.href = url; window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (cause) { windowHandle.close(); mutationError.value = getApiErrorDetails(cause, 'BOL读取失败') }
finally { bolLoadingNo.value = '' }
}
function canEdit(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canEdit === true
async function openRoute(row: DispatchListRow) {
try { const data = await getDispatch(row.dispatchNo); if (data.dispatchNo !== row.dispatchNo) throw new Error('路线排车号不一致'); routeOrigin.value = { kind: 'warehouse', label: row.warehouseName, warehouseId: row.warehouseId }; routeDestinations.value = [...new Set(data.orderDetails.map(item => item.destinationFbaWarehouse).filter((code): code is string => Boolean(code)))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })); routeOpen.value = true }
catch (cause) { mutationError.value = getApiErrorDetails(cause, '运输路线读取失败') }
}
function canCancel(row: DispatchListItem) {
return access.can(permission.dispatchCancel) && row.actions?.canCancel === true
}
function canTransfer(row: DispatchListItem) {
return access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true
}
function canRemovePallet(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true
}
function actionBlockReasons(row: DispatchListItem) {
const actions = row.actions
if (!actions) return ['操作资格暂未返回,请刷新列表']
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
}
function openOutbound(row: DispatchListItem) {
if (!row.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: row.actions.outboundNo } })
}
function openEdit(row: DispatchListItem) {
if (!canEdit(row)) return
const source = dispatchSnapshots.get(row.dispatchNo)
editRow.value = row
editItems.value = [...(source?.items || [])]
Object.assign(editForm, {
vehiclePlate: source?.vehiclePlate || '',
driverName: source?.driverName || '',
driverPhone: source?.driverPhone || '',
carrierName: source?.carrierName || '',
route: source?.route || '',
departAt: source?.departAt || '',
fbaAppointmentNo: source?.fbaAppointmentNo || '',
fbaAppointmentStatus: source?.fbaAppointmentStatus || '',
vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '',
estimatedArrivalAt: source?.estimatedArrivalAt || '',
})
mutationError.value = null
editOpen.value = true
}
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
transferIdempotencyKey.value = `web-transfer-${row.dispatchNo}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`
Object.assign(transferForm, {
plannedDeliveryAt: '',
deliveryMethod: '',
waybillNo: '',
carrierName: row.carrierName === '—' ? '' : row.carrierName,
pickupMethod: '',
loadingType: '',
vehicleType: '',
vehicleSize: '',
driverName: row.driverName === '—' ? '' : row.driverName,
driverPhone: row.driverPhone === '—' ? '' : row.driverPhone,
vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate,
appointmentAccount: '',
sellerName: '',
deliveryFee: undefined,
loadingFee: undefined,
appointmentFee: undefined,
remark: '',
})
mutationError.value = null
transferOpen.value = true
externalFreight.value = null
coveragePreview.value = null
coverageSelection.value = []
freightLoading.value = true
const generation = ++freightGeneration
try {
const [preview, coverage] = await Promise.all([getDispatchExternalFreight(row.dispatchNo), getDispatchFeeCoverage(row.dispatchNo)])
if (generation === freightGeneration && transferOpen.value) { externalFreight.value = preview; coveragePreview.value = coverage }
} catch (cause) {
if (generation === freightGeneration && transferOpen.value) mutationError.value = getApiErrorDetails(cause, '约定运费读取失败,请重新打开转派送')
} finally {
if (generation === freightGeneration) freightLoading.value = false
}
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
editSaving.value = true
mutationError.value = null
try {
await updateDispatch(editRow.value.dispatchNo, {
vehiclePlate: editForm.vehiclePlate.trim() || null,
driverName: editForm.driverName.trim() || null,
driverPhone: editForm.driverPhone.trim() || null,
carrierName: editForm.carrierName.trim() || null,
departAt: editForm.departAt || null,
fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null,
fbaAppointmentStatus: editForm.fbaAppointmentStatus || null,
vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null,
estimatedArrivalAt: editForm.estimatedArrivalAt || null,
})
editOpen.value = false
ElMessage.success('排车信息已保存')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试')
} finally {
editSaving.value = false
}
}
async function removePallet(itemValue: unknown) {
const item = itemValue as DispatchItemDto
if (!editRow.value || !canRemovePallet(editRow.value) || removingItemId.value) return
try {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', { customClass: 'admin-density-confirm',
confirmButtonText: '确认移除',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
removingItemId.value = String(item.id)
mutationError.value = null
try {
const updated = await removeDispatchItem(editRow.value.dispatchNo, String(item.id))
editItems.value = [...(updated.items || [])]
editForm.route = updated.route || ''
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
if (row) {
Object.assign(row, toListItem(updated))
editRow.value = row
}
ElMessage.success('托盘已移除,可重新排车')
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '托盘移除失败,请重试')
} finally {
removingItemId.value = ''
}
}
async function cancel(row: DispatchListItem) {
if (!canCancel(row)) return
try {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { customClass: 'admin-density-confirm',
confirmButtonText: '确认取消',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
mutationError.value = null
try {
await cancelDispatch(row.dispatchNo)
ElMessage.success('排车单已取消并释放占用,可从入仓单或换标单重新预排')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
}
}
function rowActions(row: DispatchListItem): RowActionItem[] {
if (section.value !== 'vehicle') {
const reviewPermission = section.value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'
return [
{ key: 'review', label: access.can(reviewPermission) ? '前往审核' : '查看审核', priority: 400, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'detail', label: '查看', priority: 300 },
]
}
return [
{ key: 'detail', label: '查看', priority: 300 },
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
{ key: 'trip', label: '处理原车次', priority: 150, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'outbound', label: '查看原出库', priority: 150, visible: !!row.actions?.outboundNo && access.can(permission.outboundList) },
{ key: 'edit', label: '编辑', priority: 100, visible: canEdit(row) },
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success', visible: canTransfer(row) },
{ key: 'cancel', label: '取消', tone: 'danger', divided: true, visible: canCancel(row) },
]
}
function handleRowAction(command: string, row: DispatchListItem) {
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
else if (command === 'detail') openDetail(row)
else if (command === 'bol') void openBol(row)
else if (command === 'trip') openTrip(row, 'overview')
else if (command === 'outbound') openOutbound(row)
else if (command === 'edit') openEdit(row)
else if (command === 'transfer') openTransfer(row)
else if (command === 'cancel') void cancel(row)
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && transferForm.appointmentFee == null)) {
ElMessage.warning('买约时请填写卖家姓名和买约费用')
return
}
const feeInputs = [
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
.map(([feeType, amount, feeName]) => ({
feeType,
feeName,
direction: 'RECEIVABLE' as const,
currency: 'USD' as const,
amount: Number(amount),
remark: feeName,
}))
transferSaving.value = true
try {
const result = await transferDispatchToDelivery(transferRow.value.dispatchNo, {
idempotencyKey: transferIdempotencyKey.value,
plannedDeliveryAt: transferForm.plannedDeliveryAt,
deliveryMethod: transferForm.deliveryMethod,
waybillNo: transferForm.waybillNo.trim() || undefined,
carrierName: transferForm.carrierName.trim() || undefined,
pickupMethod: transferForm.pickupMethod,
loadingType: transferForm.loadingType,
vehicleType: transferForm.vehicleType || undefined,
vehicleSize: transferForm.vehicleSize || undefined,
driverName: transferForm.driverName.trim() || undefined,
driverPhone: transferForm.driverPhone.trim() || undefined,
vehiclePlate: transferForm.vehiclePlate.trim() || undefined,
appointmentAccount: transferForm.appointmentAccount || undefined,
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
if (result.outboundStatus === 'CANCELLED') {
ElMessage.warning(`原出库单 ${result.outboundNo} 已取消,本次未生成新单据;请取消原排车后重新预排`)
} else if (result.replayed) {
ElMessage.info(`已存在出库单 ${result.outboundNo} 及关联派送单,本次未重复生成`)
} else {
ElMessage.success('已生成出库单和派送单')
}
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
} finally {
transferSaving.value = false
}
}
onMounted(load)
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" /><TripPreparationDialog v-model="prepareOpen" :warehouse-id="prepareWarehouse" :warehouse-name="prepareWarehouseName" :dispatch-nos="prepareNumbers" @saved="load()" />
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="preparationColumns" @selection-change="selectedDispatches = $event" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
<template #filters><div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
</div>
<div class="density-status-tabs" role="tablist" aria-label="排车业务分区">
<button v-for="item in visibleSections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
</div>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
<OperationErrorToast :details="mutationError" />
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。<el-button link :disabled="recovery.busy.value" @click="recoverPending">核对原请求</el-button></p>
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
<template #filters><div class="integrated-query-group"><div class="density-status-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /><p v-for="reason in actionBlockReasons(row as DispatchListItem)" :key="String(reason)">{{ reason }}</p></template>
<template #workflow="{ row }">
<el-button v-if="row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')">{{ row.workflow.tripNo }}</el-button>
<span v-else>{{ row.workflow?.tripNo || '未关联车次' }}</span>
</template>
<template v-for="review in (['dispatchReview', 'loadingReview'] as const)" :key="review" #[review]="{ row }">
<template v-if="(row as DispatchListItem).workflow">
<div>{{ reviewLabels[(row as DispatchListItem).workflow![review].status] }}</div>
<div v-if="row.workflow[review].status === 'REJECTED'">{{ row.workflow[review].reason || '—' }}</div>
<div v-if="row.workflow[review].reviewedAt">{{ formatOverseasDateTime(row.workflow[review].reviewedAt) }}</div>
</template>
<span v-else>未关联车次</span>
</template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #routeMap="{ row }"><el-button v-if="row.dispatchNo" link type="primary" @click="openRoute(row as DispatchListItem)">查看路线</el-button></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable>
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;托盘移除资格以当前服务端校验结果为准。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><p v-if="editRow?.actions?.removePalletBlockReason">{{ editRow.actions.removePalletBlockReason }}</p><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId) || !editRow || !canRemovePallet(editRow)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</ListPageLayout></AdminPage>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="load" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>
<style scoped>
.transfer-grid,
.edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 var(--admin-space-5);
}
.transfer-grid :deep(.el-select),
.transfer-grid :deep(.el-date-editor),
.transfer-grid :deep(.el-input-number),
.edit-grid :deep(.el-select),
.edit-grid :deep(.el-date-editor) {
width: 100%;
}
h3 {
margin: var(--admin-space-1) 0 var(--admin-space-5);
}
@media (max-width: 900px) {
.transfer-grid,
.edit-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,239 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import InboundOrdersView from './InboundOrdersView.vue'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import { createAppointmentForm, type AppointmentForm } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type FbaAppointmentDto, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/locations', () => ({ listOverseasLocations: vi.fn() }))
enableAutoUnmount(afterEach)
const order: InboundOrderSummaryDto = { lineId: '9223372036854775001', planId: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', orderId: 'internal-order', status: 'PENDING_ASSIGNMENT', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', warehouseId: 'WH-1', warehouseCode: 'LA1', warehouseName: '洛杉矶一仓', fbaWarehouseCode: 'ONT8', appointmentNo: 'AP-1', inboundCode: 'IN-1', version: 0 }
const warehouse: ReceivingOverseasWarehouseDto = { id: '9223372036854775002', customerId: '0', warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }
const appointment: FbaAppointmentDto = { id: 'internal-appointment', appointmentNo: 'AP-1', version: 0, originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', appointmentStatus: 'PENDING', selectedCount: 1, timezone: 'America/Los_Angeles', items: [] }
const locations: OverseasLocationDto[] = [
{ id: 'normal', warehouseId: 'WH-1', locationCode: 'N01', locationType: 'NORMAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'overflow', warehouseId: 'WH-1', locationCode: 'A0', locationType: 'SHARED_OVERFLOW', sharedOverflow: true, status: 'ACTIVE', version: 0 },
{ id: 'virtual', warehouseId: 'WH-1', locationCode: 'VIRTUAL', locationType: 'VIRTUAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'disabled', warehouseId: 'WH-1', locationCode: 'DISABLED', locationType: 'NORMAL', sharedOverflow: false, status: 'DISABLED', version: 1 },
]
function plan(): InboundPlanDetailDto {
return { id: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', sourceOrderId: 'internal-order', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', lineCount: 1, palletCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 8, totalVolumeCbm: 1,
lines: [{ id: 'line', orderId: 'internal-order', inboundCode: 'IN-1', requiredPackageCount: 2, plannedPalletCount: 1 }],
pallets: [{ id: 'pallet', palletNo: 'PALLET-1', sequenceNo: 1, warehouseLocationCode: 'OLD', actualPackageCount: 2, version: 0 }],
allocations: [{ lineId: 'line', palletId: 'pallet', palletNo: 'PALLET-1', palletOwnerPlanNo: 'IP-1', palletOwnedByCurrentPlan: true, plannedPackageCount: 2, countedPackageCount: 2, pickedPackageCount: 2, version: 0 }],
}
}
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order }], total: 1, page: 1, size: 10, pages: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(createFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateInboundPlan).mockResolvedValue({ ...order })
vi.mocked(listReceivingOverseasWarehouses).mockResolvedValue({ records: [warehouse], total: 1, page: 1, size: 200, pages: 1 })
vi.mocked(getInboundPlan).mockResolvedValue(plan())
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations, total: 4, page: 1, size: 200, pages: 1 })
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'command', status: 'COMPLETED', canMove: true })
})
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
describe('inbound appointment native dialogs', () => {
it('keeps every confirmed appointment field through fullscreen and submits the exact selected origin and lines', async () => {
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, appointmentNo: null }], total: 1, page: 1, size: 10, pages: 1 })
const wrapper = await listPage()
const table = wrapper.findComponent(DataTable)
table.vm.$emit('selection-change', table.props('data')); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '预约 ·')
const fields = dialog.getComponent(FbaAppointmentFields)
const model = fields.props('model')
model.appointmentStatus = 'SUCCESS'; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(createFbaAppointment).not.toHaveBeenCalled()
Object.assign(model, { plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: ' EXT-1 ', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentRemark: ' 约仓确认 ', startOffset: '-07:00', endOffset: '-08:00' })
await flushPromises(); await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(fields.props('continuous')).toBe(true)
expect(dialog.get('[aria-label="预约来源信息"]').text()).toContain('保存后生成')
expect(fields.findAll('details')).toHaveLength(1)
dialog.vm.$emit('confirm'); await flushPromises()
expect(createFbaAppointment).toHaveBeenCalledExactlyOnceWith({ originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', lineIds: [order.lineId], idempotencyKey: expect.any(String), details: {
plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: 'EXT-1', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentStatus: 'SUCCESS', appointmentRemark: '约仓确认', startOffset: '-07:00', endOffset: '-08:00',
} })
})
it('locks an uncertain edit to the original zero-version request and keeps close and route guards intact', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateFbaAppointment).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...appointment })
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields)
fields.props('model').appointmentRemark = '交接备注'; await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledTimes(1)
const original = vi.mocked(updateFbaAppointment).mock.calls[0]!
expect(original[1]).toMatchObject({ version: 0, idempotencyKey: expect.any(String) })
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
reject(new ApiResponseError('结果暂未确认', 'A0503', 503, null, 'appointment-popup-request')); await flushPromises()
expect(fields.props('disabled')).toBe(true)
expect(dialog.text()).toContain('appointment-popup-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['permission', 'replacement', 'cancelled'] as const)('keeps %s appointments read-only in the real continuous form', async mode => {
if (mode === 'permission') can.mockImplementation(code => code !== permission.inboundOrderAppointment)
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment, appointmentStatus: mode === 'cancelled' ? 'CANCELLED' : 'PENDING', replacementAppointmentNos: mode === 'replacement' ? ['AP-NEW'] : [] })
const wrapper = await listPage()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === (mode === 'permission' ? '查看预约' : '预约'))!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '查看预约')
expect(dialog.getComponent(FbaAppointmentFields).props('disabled')).toBe(true)
expect(dialog.get('input[placeholder="有需交接的事项时填写"]').attributes('disabled')).toBeDefined()
expect(dialog.props('showConfirm')).toBe(false)
if (mode === 'replacement') expect(dialog.text()).toContain('替代预约:AP-NEW')
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
})
it('keeps the failed-appointment reason required and clears offsets after a confirmed-time change', async () => {
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields), model = fields.props('model')
model.appointmentStatus = 'FAILED'; model.appointmentRemark = ' '; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateFbaAppointment).not.toHaveBeenCalled()
model.appointmentStatus = 'SUCCESS'; model.startOffset = '-07:00'; model.endOffset = '-08:00'; await flushPromises()
const dates = fields.findAllComponents({ name: 'ElDatePicker' })
dates.find(date => date.props('placeholder') === '目的仓当地时间')!.vm.$emit('change', '2026-11-01T10:00:00')
dates.find(date => date.props('placeholder') === '未明确可留空')!.vm.$emit('change', '2026-11-01T11:00:00'); await flushPromises()
expect(model.startOffset).toBe(''); expect(model.endOffset).toBe('')
})
it('keeps the shared default layout and all model fields available to existing consumers', () => {
const model: AppointmentForm = createAppointmentForm({ ...appointment, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-1', appointmentAt: '2026-10-21T10:00:00' })
const wrapper = mount(FbaAppointmentFields, { props: { model, appointmentNo: 'AP-1' }, global: { plugins: [ElementPlus] } })
expect(wrapper.find('.appointment-fields').exists()).toBe(true)
expect(wrapper.find('.appointment-editor-table').exists()).toBe(false)
expect(wrapper.findAllComponents({ name: 'ElDatePicker' })).toHaveLength(5)
expect(wrapper.findAll('input').some(input => input.element.value === 'EXT-1')).toBe(true)
})
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
})
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openEditPlan()
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(select.find('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(select.props('modelValue')).toBe(warehouse.id)
expect(dialog.text()).toContain('plan-popup-request')
})
it('does not save while the plan warehouse candidates are loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await openEditPlan()
expect(dialog.props('loading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
expect(dialog.props('loading')).toBe(false)
})
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
const { dialog } = await openMove()
expect(getInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', 'C-1')
expect(listOverseasLocations).toHaveBeenCalledExactlyOnceWith('WH-1', { page: 1, size: 200 })
expect(dialog.get('[aria-label="移库来源"]').findAll('input')).toHaveLength(0)
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['N01', 'A0'])
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'A0'); await dialog.get('input[placeholder="请输入移库原因"]').setValue(' 上架到溢出位 ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledExactlyOnceWith({ overseasWarehouseId: 'WH-1', sourcePalletCode: 'PALLET-1', targetLocationCode: 'A0', expectedPalletVersion: 0, reason: '上架到溢出位', idempotencyKey: expect.stringMatching(/^admin-inbound-move-PALLET-1-\d+$/) })
expect(dialog.props('modelValue')).toBe(false)
})
it('locks pending move inputs and retains the chosen location, reason and API error after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(moveInboundPallet).mockReturnValue(new Promise((_, no) => { reject = no }))
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await dialog.get('input[placeholder="请输入移库原因"]').setValue('现场上架')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="请输入移库原因"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('移库未完成', 'A0409', 409, null, 'move-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('N01')
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('现场上架')
expect(dialog.text()).toContain('move-popup-request')
})
it('keeps failed location queries explicit and retries without losing the move reason', async () => {
vi.mocked(listOverseasLocations).mockRejectedValueOnce(new ApiResponseError('库位读取失败', 'A0503', 503, null, 'location-popup-request')).mockResolvedValueOnce({ records: locations, page: 1, size: 200, total: 4, pages: 1 })
const { dialog } = await openMove()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('location-popup-request')
await dialog.get('input[placeholder="请输入移库原因"]').setValue('保留原因')
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(listOverseasLocations).toHaveBeenCalledTimes(2)
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('保留原因')
expect(dialog.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
})
it('keeps server move blockers visible and does not close the form or refresh as success', async () => {
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'blocked', status: 'REJECTED', canMove: false, blockReason: '托盘正在作业,禁止移库' })
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('托盘正在作业,禁止移库')
expect(getInboundPlan).toHaveBeenCalledTimes(1)
})
it('shows empty physical locations and rejects a move without a target or reason', async () => {
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations.filter(location => location.locationType === 'VIRTUAL' || location.status === 'DISABLED'), page: 1, size: 200, total: 2, pages: 1 })
const { dialog } = await openMove()
expect(dialog.text()).toContain('暂无可用普通库位或 A0')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
})
it('keeps missing pallet versions unavailable instead of treating them as valid zero versions', async () => {
const value = plan(); delete value.pallets[0]!.version
vi.mocked(getInboundPlan).mockResolvedValue(value)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
const button = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!
expect(button.attributes('disabled')).toBeDefined()
expect(listOverseasLocations).not.toHaveBeenCalled()
})
})
@@ -19,6 +19,7 @@ const planNo = computed(() => String(route.params.orderId || ''))
const customerId = computed(() => String(route.query.customerId || ''))
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
const moveLocationsLoading = ref(false); const moveLocationError = ref<ApiErrorDetails | null>(null); const moveError = ref<ApiErrorDetails | null>(null)
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function receivingWarehouse(code: unknown, name: unknown) {
@@ -63,20 +64,29 @@ const palletRows = computed(() => (detail.value?.allocations || []).map((allocat
palletNo: text(allocation.palletNo),
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? null, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
}
}))
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
async function openMove(row: typeof palletRows.value[number]) {
if (moveSaving.value || moveLocationsLoading.value || row.palletVersion == null || row.palletNo === '—') return
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveError.value = null; moveOpen.value = true
await loadMoveLocations()
}
async function loadMoveLocations() {
if (moveSaving.value || moveLocationsLoading.value) return
moveLocationsLoading.value = true; moveLocationError.value = null; locationOptions.value = []; targetLocationCode.value = ''
try { const result = await listOverseasLocations(String(detail.value?.overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') }
catch (cause) { moveLocationError.value = getApiErrorDetails(cause, '可用库位加载失败,请重试') }
finally { moveLocationsLoading.value = false }
}
async function submitMove() {
if (!moveOpen.value || moveSaving.value || moveLocationsLoading.value || moveLocationError.value) return
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
moveSaving.value = true
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
moveSaving.value = true; moveError.value = null
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { moveError.value = { message: '入库单未返回海外仓范围,暂不能移库' }; return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { moveError.value = { message: result.blockReason || '当前托盘不可移库' }; return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { moveError.value = getApiErrorDetails(error, '移库失败') } finally { moveSaving.value = false }
}
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
function back() { void router.push(appointmentReturnTarget(route.query) || { name: 'overseas-inbound-orders' }) }
@@ -86,10 +96,18 @@ onMounted(load)
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></el-form></AppDialog></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="移库来源"><h3 class="density-dialog-section__title">来源信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ referenceValue }}</td></tr><tr><th scope="row">托盘号</th><td>{{ moveRow?.palletNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="moveLocationError" compact title="可用库位加载失败" :message="moveLocationError.message" :loading="moveLocationsLoading" @retry="loadMoveLocations"><template #details><ApiErrorMeta :details="moveLocationError" /></template></LoadErrorState>
<LoadErrorState v-if="moveError" compact title="移库未完成" :message="moveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="moveError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="移库登记"><h3 class="density-dialog-section__title">移库登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="moveSaving || moveLocationsLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable :loading="moveLocationsLoading" placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ''}`" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" placeholder="请输入移库原因" /></el-form-item></td></tr>
</tbody></table></el-form><el-alert v-if="!moveLocationsLoading && !moveLocationError && !availableLocations.length" title="暂无可用普通库位或 A0" type="info" :closable="false" show-icon /></section>
</div></AppDialog></template>
<style scoped>
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
@@ -16,7 +16,7 @@ const render=()=>mount(InboundOrdersView,{global:{directives:{loading:{}},stubs:
AdminPage:{template:'<main><slot/></main>'},ListPageLayout:{template:'<div><slot/></div>'},
DataTable:{props:['data'],template:'<div><slot v-for="row in data" name="actions" :row="row"/></div>'},
AppDialog:{name:'AppDialog',props:['modelValue','beforeClose','showConfirm'],template:'<section v-if="modelValue"><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></section>'},
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
ElButton:{template:'<button><slot/></button>'}, ElAlert:{props:['title'],template:'<p role="status">{{ title }}</p>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
}}})
beforeEach(()=>{vi.clearAllMocks();access.canWrite=true;vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})})
it('opens linked appointment details for list-only users and blocks saving',async()=>{
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import InboundOrdersView from './InboundOrdersView.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = vi.hoisted(() => ({ codes: [] as string[] }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => access.codes.includes(code) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('../api/inboundOrders', () => ({ listInboundOrders: vi.fn().mockResolvedValue({ records: [], total: 0 }), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', props: ['modelValue', 'orders'], template: '<section v-if="modelValue" data-dispatch-dialog />' } }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
function render() {
return mount(InboundOrdersView, { global: { directives: { loading: {} }, stubs: {
AdminPage: { template: '<main><slot /></main>' }, ListPageLayout: { template: '<div><slot /></div>' },
DataTable: { template: '<div><slot name="toolbar-actions" /></div>' },
ElButton: { template: '<button><slot /></button>' },
AppDialog: true, FbaAppointmentFields: true, QueryToolbar: true, OperationErrorToast: true,
} } })
}
beforeEach(() => { vi.clearAllMocks(); access.codes = [permission.inboundOrderList] })
it('opens the existing smart-dispatch flow with dispatch list/create and no legacy smart permission or selected rows', async () => {
access.codes.push(permission.dispatchList, permission.dispatchCreate)
const wrapper = render()
try {
await flushPromises()
const button = wrapper.findAll('button').find(button => button.text() === '智能排车')!
expect(button.exists()).toBe(true)
await button.trigger('click')
expect(wrapper.find('[data-dispatch-dialog]').exists()).toBe(true)
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('orders')).toEqual([])
} finally { wrapper.unmount() }
})
it.each([
['list only', [permission.dispatchList]],
['create only', [permission.dispatchCreate]],
['legacy smart only', ['platform:overseas-warehouse:inbound-order:smart-dispatch']],
['list plus legacy smart', [permission.dispatchList, 'platform:overseas-warehouse:inbound-order:smart-dispatch']],
])('blocks the button and direct handler for %s', async (_, codes) => {
access.codes.push(...codes)
const wrapper = render()
try {
await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '智能排车')).toBe(false)
const state = wrapper.vm as unknown as { openSmartDispatch(): void }
state.openSmartDispatch()
await flushPromises()
expect(wrapper.find('[data-dispatch-dialog]').exists()).toBe(false)
} finally { wrapper.unmount() }
})
@@ -24,6 +24,7 @@ import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel
import type { InboundOrderListItem } from '../types'
const access = useAccessPreviewStore()
const canCreateDispatch = computed(() => access.can(permission.dispatchList) && access.can(permission.dispatchCreate))
const router = useRouter()
const rows = ref<InboundOrderListItem[]>([])
const selectedRows = ref<InboundOrderListItem[]>([])
@@ -61,7 +62,7 @@ const pendingAppointment = ref<{appointmentNo: string | null; version: number; o
const appointmentViewOnly = ref(false)
const appointmentReplacementNos = ref<string[]>([])
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约')
const appointmentTitle = computed(() => `${appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约'} · ${editingAppointmentNo.value || `${appointmentSelectedCount.value} 条入库单`}`)
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) {
@@ -145,6 +146,7 @@ function openAppointment() {
}
function openSmartDispatch() {
if (!canCreateDispatch.value) return
smartDispatchOpen.value = true
}
@@ -220,7 +222,7 @@ 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.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 } }
async function submitPlanEdit() { if (planEditSaving.value || planEditLoading.value || !access.can(permission.inboundOrderEdit)) return; 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>
@@ -234,28 +236,29 @@ onMounted(() => { void load() })
<template #notices><template v-if="loadError"><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<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 #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="canCreateDispatch" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<p v-if="appointmentReplacementNos.length">原批次已被替代,当前只读。替代预约:{{ appointmentReplacementNos.join('、') }}</p><FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="mutationError" compact title="预约保存失败" :message="mutationError.message" :loading="saving" :show-retry="!appointmentReadOnly" :retry-text="pendingAppointment ? '重试本次保存' : '重新提交'" @retry="submitAppointment"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<FbaAppointmentFields continuous :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog" v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form class="density-form" label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
</AdminPage>
</template>
<style scoped>
.appointment-summary { margin-bottom: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #cdddfd; border-radius: var(--admin-radius); background: #f5f9ff; color: #52627a; font-size: var(--admin-font-body); }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }
.row-actions :deep(.el-button) { flex: 0 0 auto; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>
@@ -50,9 +50,8 @@ async function exportLedger() { exportLoading.value = true; try { const range =
<template #toolbar-start><h1>进销存记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
</template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -1,24 +1,54 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLastMileOrder } from '../api/lastMileOrders'
import { createLastMileOrderFee, getLastMileOrder } from '../api/lastMileOrders'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { getWarehouseTransfer } from '../api/warehouseTransfers'
const can = vi.hoisted(() => vi.fn(() => false))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
const feeSupplier: Supplier = { id: '9223372036854775001', tenantId: '0', name: '测试供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
async function openFeeEditor() {
can.mockReturnValue(true)
const wrapper = mount(LastMileOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.get('#tab-fees').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click'); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
return { wrapper, dialog }
}
async function fillFee(dialog: VueWrapper, direction: 'RECEIVABLE' | 'PAYABLE') {
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'STORAGE_FEE'); selects[0]!.vm.$emit('change', 'STORAGE_FEE')
selects[1]!.vm.$emit('update:modelValue', direction); await flushPromises()
if (direction === 'PAYABLE') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择供应商')!.vm.$emit('update:modelValue', feeSupplier.id)
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择币种')!.vm.$emit('update:modelValue', direction === 'PAYABLE' ? 'CNY' : 'USD')
const numbers = dialog.findAllComponents({ name: 'ElInputNumber' })
numbers[0]!.vm.$emit('update:modelValue', 80); numbers[1]!.vm.$emit('update:modelValue', 2)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
dates[0]!.vm.$emit('update:modelValue', '2026-10-20'); dates[1]!.vm.$emit('update:modelValue', '2026-10-01'); dates[2]!.vm.$emit('update:modelValue', '2026-10-20')
await flushPromises()
await dialog.get('input[placeholder="如 箱、托、天、m³"]').setValue('托')
}
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
it('counts mixed parcel and box receipts by their physical unit instead of calling both parcels', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL' }, { receiptNo: 'B1', unitType: 'BOX', length: 30, width: 20, height: 10, weight: 2 }],
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', items: [] }] } })
returnReceipt: { order: { returnNo: 'RT-1', expectedQuantity: 8, receivedQuantity: 8 }, receipts: [{ receiptNo: 'P1', unitType: 'PARCEL', receivedQuantity: 1, boxCount: 0, signedAt: '2026-10-20T10:00:00' }, { receiptNo: 'B1', unitType: 'BOX', receivedQuantity: 1, boxCount: 1, signedAt: '2026-10-20T10:00:00', length: 30, width: 20, height: 10, weight: 2 }],
cartons: [{ receiptNo: 'P1', cartonMark: 'PK1', countStatus: 'COUNTED', items: [] }, { receiptNo: 'B1', cartonMark: 'RC1', countStatus: 'COUNTED', items: [] }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('#tab-return-receipt').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('签收实物明细(1 包裹 · 1 箱)')
@@ -100,6 +130,9 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
beforeEach(() => {
can.mockReset().mockReturnValue(false)
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [feeSupplier], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createLastMileOrderFee).mockReset().mockResolvedValue({ id: 'fee-test', sourceFeeId: 'source-fee-test', sourceVersion: 1, customerId: 'customer-1', feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction: 'RECEIVABLE', currency: 'CNY', amount: 80, originalCurrency: 'USD', originalAmount: 80, exchangeRate: 1, exchangeRateMonth: '2026-10', reviewStatus: 'PENDING', publicationStatus: 'PENDING', sourceStatus: 'AVAILABLE', businessDate: '2026-10-20', createdAt: '', updatedAt: '' })
push.mockReset()
vi.mocked(getWarehouseTransfer).mockReset()
vi.mocked(getPlatformWarehouse).mockReset()
@@ -215,4 +248,37 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(rows[1].text()).toContain('处理中')
expect(rows[1].text()).toContain('5')
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('keeps %s fee fields and exact payload through fullscreen editing', async direction => {
const { dialog } = await openFeeEditor()
expect(dialog.props('title')).toContain('FL-WH-001')
expect(dialog.props('width')).toContain('720px')
await fillFee(dialog, direction)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
const payable = direction === 'PAYABLE'
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', {
feeItemCode: 'STORAGE_FEE', feeItemName: '仓租', direction, currency: payable ? 'CNY' : 'USD', amount: 80, businessDate: '2026-10-20',
supplierId: payable ? feeSupplier.id : undefined, supplierName: payable ? feeSupplier.name : undefined, supplierCategory: payable ? feeSupplier.category : undefined,
settlementMode: payable ? 'ON_ACCOUNT' : undefined, chargeableQuantity: 2, quantityUnit: '托', unitPrice: 40, periodStart: '2026-10-01', periodEnd: '2026-10-20',
})
})
it('keeps fee required-field validation before a request', async () => {
const { dialog } = await openFeeEditor()
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(createLastMileOrderFee).not.toHaveBeenCalled()
})
it('locks fee controls and preserves failed values and diagnostic context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createLastMileOrderFee).mockReturnValue(new Promise((_, no) => { reject = no }))
const { wrapper, dialog } = await openFeeEditor()
await fillFee(dialog, 'RECEIVABLE')
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="如 箱、托、天、m³"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('费用保存失败', 'TEST_REJECTED', 409, null, 'fee-test-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.props('modelValue')).toBe(80)
expect(wrapper.findComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'fee-test-request' })
})
})
@@ -258,6 +258,7 @@ async function openFeeDialog() {
}
}
async function saveFee() {
if (saving.value) return
if (!feeForm.itemCode || !feeForm.itemName.trim() || feeForm.amount <= 0 || !feeForm.businessDate) { ElMessage.warning('请填写费用项目、业务日期和大于 0 的金额'); return }
const supplier = supplierOptions.value.find((item) => String(item.id) === feeForm.supplierId)
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
@@ -366,17 +367,17 @@ onMounted(load)
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form density-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></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 label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
</div></el-form>
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<section class="density-dialog-section" data-tone="work" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving"><table class="density-table density-table--editable fee-editor-table"><tbody>
<tr><td><el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item></td><td><el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
<tr><td><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></td><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td><el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td><td><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item></td></tr>
</tbody></table></el-form>
</section>
</AppDialog>
</OverseasDetailShell>
</template>
@@ -390,12 +391,10 @@ onMounted(load)
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
.plan-panel { margin-bottom: var(--admin-space-5); }
.related-panel { margin-top: var(--admin-space-5); }
.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: var(--admin-space-5); }
.fee-form-full { grid-column: 1 / -1; }
.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-editor-table { table-layout: fixed; }
.fee-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 1100px) { .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, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full, .detail-fields > div.wide { grid-column: auto; } }
@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; } .detail-fields > div.wide { grid-column: auto; } }
.detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
.info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
@@ -169,7 +169,7 @@ function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
function cabinetDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '按柜生成入库计划预览')!
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('按柜生成入库计划预览'))!
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
@@ -307,9 +307,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '生成入库计划'], false],
[['last-mile-order:edit'], ['查看', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false],
[['inbound-order:create'], ['查看', '入库'], false],
[['last-mile-order:edit'], ['查看', '修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -380,7 +380,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
}], page: 1, size: 10, total: 1, pages: 1 })
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核', expect.objectContaining({ distinguishCancelAndClose: true }))
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核 · CD-TEST-1', expect.objectContaining({ distinguishCancelAndClose: true }))
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'APPROVE', undefined)
expect(button(wrapper, '审核')).toBeUndefined()
})
@@ -469,11 +469,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.text()).toContain('查看')
expect(actions.text()).not.toContain('生成入库计划')
expect(actions.text()).not.toContain('入库')
expect(actions.text()).not.toContain('暂扣订单')
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
@@ -481,7 +481,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).toContain('尾程单号')
expect(wrapper.text()).toContain('海外仓')
expect(wrapper.text()).toContain('转仓')
expect(wrapper.text()).toContain('生成入库计划')
expect(wrapper.text()).toContain('入库')
expect(wrapper.text()).toContain('按柜批量生成')
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
@@ -545,24 +545,17 @@ describe('LastMileOrdersView pending order and container conversion', () => {
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 button(wrapper, '入库')!.trigger('click')
await flushPromises()
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 · 洛杉矶接柜仓')
const expectedArrivalInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '当前柜预计到仓时间')
expect(expectedArrivalInput?.props('disabled')).toBe(true)
expect(expectedArrivalInput?.props('modelValue')).toBe('2026-09-01 08:00')
expect(document.body.textContent).toContain('同柜所有入库计划保持一致')
expect(document.body.textContent).toContain('接柜仓按柜统一')
const sourceFacts = wrapper.get('[aria-label="入库来源"]')
expect(sourceFacts.find('input').exists()).toBe(false)
expect(sourceFacts.text()).toContain('IN-LOCKED')
expect(sourceFacts.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(sourceFacts.text()).toContain('2026-09-01 08:00')
expect(wrapper.find('button[aria-label="查看入库来源说明"]').exists()).toBe(true)
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -572,7 +565,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
@@ -598,7 +591,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
orderTypeCode: 'ONE_CLICK_DROPSHIP',
currentStatusCode: 'PENDING_ASSIGNMENT',
})])
await button(wrapper, '生成入库计划')!.trigger('click')
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
@@ -610,7 +603,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
await button(wrapper, '生成入库计划')!.trigger('click')
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
@@ -634,12 +627,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
} as never)
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
await button(wrapper, '生成入库计划')!.trigger('click')
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const palletInput = wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' })
expect(palletInput.props('disabled')).toBe(true)
expect(palletInput.props('modelValue')).toBe(1)
expect(wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
expect(wrapper.get('.frozen-pallet-count').text()).toBe('1')
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
;([...document.body.querySelectorAll('button')]
@@ -656,6 +648,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
await cabinetDialog(wrapper).get('button[aria-label="查看按柜生成说明"]').trigger('focus')
await new Promise(resolve => setTimeout(resolve, 200)); await flushPromises()
expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令')
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -944,7 +938,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('starts cabinet plan generation directly from the workbench card', async () => {
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '生成入库计划')!.trigger('click')
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('头程已有 4 个托盘码,海外仓补充 1 个,最终生成 5 个托盘')
expect(document.body.textContent).toContain('头程已有 4 托 / 海外仓补充 1 托')
@@ -974,7 +968,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致')
expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
await wrapper.get('.container-workbench-card__detail').trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
@@ -1183,12 +1177,118 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'INBOUND_COMPLETED', sourceOrderNo: 'RETURN-1' })])
const actions = wrapper.get('.data-table__business-actions')
expect(actions.text()).not.toContain('生成入库计划')
expect(actions.text()).not.toContain('入库')
const packing = actions.findAll('button').find(action => action.text().includes('创建退货合箱计划(1)'))!
await packing.trigger('click')
await flushPromises()
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
})
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('width')).toContain('720px')
expect(dialog.props('title')).toContain('LMO-260827-0001')
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
await dialog.get('textarea').setValue('全屏后保留')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('全屏后保留')
})
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
await dialog.get('textarea').setValue('保留入库备注')
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('入库失败', 'TEST_REJECTED', 409, null, 'plan-test-request'))
await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留入库备注')
expect(dialog.props('modelValue')).toBe(true)
expect(document.body.textContent).toContain('plan-test-request')
})
it.each([false, true])('keeps hold/release flags, reason and zero version for detained=%s', async detained => {
const wrapper = await mountView()
const row = selectedRow({ detained, currentStatusCode: 'INBOUND_COMPLETED', version: 0 })
const vm = wrapper.vm as unknown as { openHold(row: object): void }
vm.openHold(row); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith(detained ? '解除暂扣 ·' : '暂扣订单 ·'))!
await dialog.get('textarea').setValue(' 原因 ')
dialog.findAllComponents({ name: 'ElRadioGroup' })[1]!.vm.$emit('update:modelValue', false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
})
it('renders all eight node times and submits the complete set with zero version', async () => {
const wrapper = await mountView()
const row = selectedRow()
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(row)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))!
expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8)
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
expect(dates).toHaveLength(8)
dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [
{ nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' },
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null },
{ nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null },
{ nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null },
], expect.stringContaining('last-mile-manual-nodes-'))
})
it('keeps every destination group in the main preview body through fullscreen', async () => {
const original = await getContainerInboundPlanPreview(bookingId)
const group = original.groups[0]!
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...original, totalOrderCount: 13, readyOrderCount: 13, pendingOrderCount: 13, pendingPalletCount: 13, sourcePalletCount: 13, supplementPalletCount: 0,
groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0,
orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })),
})
const wrapper = await openPending()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
const dialog = cabinetDialog(wrapper)
expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13)
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.text()).toContain('业务单-12')
expect(dialog.find('.container-preview__scroll').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
})
it('keeps transfer drafts locked during submission and editable after rejection', async () => {
let reject!: (reason: unknown) => void
vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))!
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', fbaWarehouse.id)
await dialog.get('textarea').setValue('保留转仓备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '确认转仓')!.trigger('click'); await flushPromises()
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转仓失败', 'TEST_REJECTED', 409, null, 'transfer-test-request')); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留转仓备注')
expect(dialog.props('modelValue')).toBe(true)
})
it('keeps rework selection and reason through fullscreen without issuing a command', async () => {
const wrapper = await mountView()
;(wrapper.vm as unknown as { reworkRelabel(row: object): void }).reworkRelabel(selectedRow({ relabelOrderNo: 'RL-TEST-1', relabelStatusCode: 'CUSTOMER_CONFIRM_PENDING' }))
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('换标退回重做 ·'))!
expect(dialog).toBeDefined()
await dialog.get('textarea').setValue('保留重做原因')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留重做原因')
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('RL-TEST-1')
})
})
describe('tail-leg pallet merge toolbar', () => {
@@ -9,6 +9,7 @@ import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -77,7 +78,7 @@ async function submitPalletMerge(required: boolean) {
try {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, label,
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, `${label} · ${selection.length} 票订单`,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
@@ -910,12 +911,12 @@ async function reviewDiscard(row: LastMileOrderListItem) {
let decision: 'APPROVE' | 'REJECT' = 'APPROVE'
let remark: string | undefined
try {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, '废弃申请审核', {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, `废弃申请审核 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
})
} catch (action) {
if (action !== 'cancel') throw action
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃申请', {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回废弃申请 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
confirmButtonText: '确认驳回', cancelButtonText: '取消',
@@ -938,14 +939,14 @@ async function reviewReturn(source: object) {
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', `退货预报审核 · ${returnNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, row.customerId, 'APPROVE')
ElMessage.success('退货预报已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回退货预报', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回退货预报 · ${returnNo}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
@@ -957,14 +958,14 @@ async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', '贴标单审核', { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', `贴标单审核 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', '驳回贴标单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', `驳回贴标单 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
@@ -1000,9 +1001,9 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
]
@@ -1023,7 +1024,7 @@ async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', `换标完成确认 · ${relabelNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
@@ -1124,7 +1125,7 @@ onMounted(() => { void load() })
<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)">{{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '生成入库计划' }}</el-button>
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">{{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '入库' }}</el-button>
</span>
</article>
</div>
@@ -1135,7 +1136,7 @@ onMounted(() => { void load() })
</div>
</section></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">入库</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
@@ -1146,68 +1147,42 @@ onMounted(() => { void load() })
</DataTable>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
<el-form class="density-form" v-else label-position="top">
<el-form-item label="目的 FBA 仓" required>
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
</el-select>
</el-form-item>
<AppDialog class="admin-density-dialog" v-model="transferOpen" :title="`转仓 · ${selectedRows.length} 票订单`" fullscreenable confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions"><template #details><ApiErrorMeta :details="transferWarehouseError" /></template></LoadErrorState>
<section v-else class="density-dialog-section" data-tone="work" aria-label="转仓登记"><h3 class="density-dialog-section__title">转仓登记</h3>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="目的 FBA 仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse"><el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item></td></tr>
</tbody></table></el-form>
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form class="density-form" v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
</el-select>
</el-form-item>
</el-form>
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview">
<div class="container-preview__context">
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
</div>
<div v-if="!containerIsResourceRecovery" class="container-preview__metrics">
<div class="container-preview__metric"><span>头程已有托盘码</span><strong>{{ containerPreview.sourcePalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>海外仓补充托盘码</span><strong>{{ containerPreview.supplementPalletCount }}</strong></div>
<div class="container-preview__metric"><span>最终生成托盘</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
<div class="container-preview__metric"><span>本次处理订单</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
</div>
<div v-else class="container-preview__metrics">
<div class="container-preview__metric"><span>已保存计划托盘</span><strong>{{ containerPreview.totalPlannedPalletCount }}</strong></div>
<div class="container-preview__metric container-preview__metric--primary"><span>资源确认</span><strong>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</strong></div>
</div>
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" class="container-convert-alert" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="container-conversion-result">
<header><strong>{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</strong><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
<div class="container-conversion-result__plans">
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
<p v-if="plan.pallets.length"><span v-for="pallet in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet" :title="pallet.palletNo || '托盘码暂未返回'">{{ pallet.palletNo || '—' }}</span></p>
<p v-else class="container-conversion-result__empty">暂无托盘码</p>
</article>
</div>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" :title="`按柜生成入库计划预览${containerPreview?.containerNo ? ' · ' + containerPreview.containerNo : ''}`" width="min(1240px, calc(100vw - 80px))" fullscreenable :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="选择待分配柜"><h3 class="density-dialog-section__title">选择待分配柜 <InlineHelp title="按柜生成说明">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</InlineHelp></h3>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()"><template #details><ApiErrorMeta :details="containerLoadError" /></template></LoadErrorState>
<el-form v-else class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="待分配柜" required><el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview"><el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" /></el-select></el-form-item></td></tr></tbody></table></el-form>
</section>
<div class="container-preview__scroll">
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
<header class="container-preview__group-header">
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</strong>
<span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span>
<span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
</header>
<el-alert v-if="containerPreviewError" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="全柜预览汇总"><h3 class="density-dialog-section__title">全柜预览</h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">柜号</th><td><strong class="container-preview__identity" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</strong></td><th scope="row">海外接柜仓</th><td>{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</td></tr>
<template v-if="!containerIsResourceRecovery"><tr><th scope="row">头程已有托盘码</th><td>{{ containerPreview.sourcePalletCount }}</td><th scope="row">海外仓补充托盘码</th><td>{{ containerPreview.supplementPalletCount }}</td></tr><tr><th scope="row">最终生成托盘</th><td>{{ containerPreview.pendingPalletCount }}</td><th scope="row">本次处理订单</th><td>{{ containerPreview.pendingOrderCount }}</td></tr></template>
<tr v-else><th scope="row">已保存计划托盘</th><td>{{ containerPreview.totalPlannedPalletCount }}</td><th scope="row">资源确认</th><td>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="containerPreview.blockedOrderCount > 0" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="density-dialog-section container-conversion-result" data-tone="summary" aria-label="生成结果">
<h3 class="density-dialog-section__title">{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</h3>
<p class="density-dialog-note"><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></p>
<table class="density-table"><thead><tr><th>计划号</th><th>状态</th><th>托盘码</th></tr></thead><tbody class="container-conversion-result__plans"><tr v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan"><td>{{ plan.planNo }}</td><td><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></td><td><template v-if="plan.pallets.length"><span v-for="(pallet, index) in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet">{{ index ? '、' : '' }}{{ pallet.palletNo || '—' }}</span></template><span v-else>暂无托盘码</span></td></tr></tbody></table>
</section>
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="density-dialog-section" data-tone="source" aria-label="目的地分组明细">
<h3 class="density-dialog-section__title">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</h3>
<p class="density-dialog-note"><span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span><span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span></p>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="businessOrderNo" label="订单号" min-width="150" show-overflow-tooltip />
@@ -1265,67 +1240,55 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form" label-position="top">
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。</small><el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></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="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></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="否" /><small>仅作为现场可选提示,不强制工人合托。</small></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-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" placeholder="请输入托数" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><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></td></tr>
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<el-form class="command-form density-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>
<section class="density-dialog-section" data-tone="work" aria-label="暂扣变更登记"><h3 class="density-dialog-section__title">变更登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><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></td></tr>
<tr><td><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></td></tr>
<tr><td><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" 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>
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<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>
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" title="换标退回重做" width="min(620px, calc(100vw - 28px))" confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<el-form class="density-form" label-position="top">
<el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item>
<el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item>
</el-form>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<section class="density-dialog-section" data-tone="work" aria-label="换标重做登记"><h3 class="density-dialog-section__title">退回重做</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
</template>
<style scoped>
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
.manual-nodes-editor th { width: 180px; }
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { min-width: 0; }
.row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.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: var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: var(--admin-space-5); overflow: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 58fr); align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: var(--admin-space-5); }
.container-preview { margin-top: var(--admin-space-5); }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-preview__identity { color: var(--cc-text); font-size: var(--admin-font-title); font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; }
.container-preview__metric--primary strong { color: var(--cc-primary); }
.container-preview__metric--danger strong { color: var(--el-color-danger); }
.container-preview__scroll { max-height: 48vh; margin-top: var(--admin-space-5); padding-right: var(--admin-space-2); overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: var(--admin-space-5); }
.container-preview__group-header { min-height: 42px; padding: 0 var(--admin-space-5); border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
.container-conversion-result { margin-top: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid #86efac; border-radius: var(--admin-radius); background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
.container-conversion-result__plan { min-width: 0; padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #bbf7d0; border-radius: var(--admin-radius); background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); margin: var(--admin-space-3) 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: var(--admin-radius); background: #dcfce7; color: #166534; font-size: var(--admin-font-caption); text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1356,10 +1319,6 @@ onMounted(() => { void load() })
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--admin-font-body); }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: var(--admin-space-5) 0; border: 1px solid var(--cc-border); }
.container-preview__metric { display: flex; min-height: var(--admin-row-height); padding: var(--admin-space-5) var(--admin-space-5); border: 0; border-radius: 0; background: var(--admin-surface); flex-direction: row; justify-content: flex-start; align-items: baseline; gap: var(--admin-space-3); border-right: 1px solid var(--cc-border); }
.container-preview__metric:last-child { border-right: 0; }
.container-preview__metric strong { margin-top: var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-body); line-height: 20px; margin: 0; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
</style>
@@ -0,0 +1,153 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { OverseasLocationDto } from '../api/locations'
import LocationsView from './LocationsView.vue'
const api = vi.hoisted(() => ({ warehouses: vi.fn(), list: vi.fn(), all: vi.fn(), create: vi.fn(), update: vi.fn(), status: vi.fn(), remove: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses }))
vi.mock('../api/locations', () => ({ listOverseasLocations: api.list, listAllOverseasLocations: api.all, createOverseasLocation: api.create, updateOverseasLocation: api.update, changeOverseasLocationStatus: api.status, deleteOverseasLocation: api.remove }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function location(overrides: Partial<OverseasLocationDto> = {}): OverseasLocationDto { return { id: '9007199254740993', warehouseId: '9007199254740994', locationCode: 'LOC-001', locationType: 'NORMAL', sharedOverflow: false, status: 'INACTIVE', maxLoadKg: 1000, occupiedPalletCount: 0, version: 0, remark: '原备注', ...overrides } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.warehouses.mockResolvedValue({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] })
api.all.mockResolvedValue({ records: [location()] }); api.list.mockResolvedValue({ records: [location()] })
api.create.mockResolvedValue('9007199254740995'); api.update.mockResolvedValue(null); api.status.mockResolvedValue(null); api.remove.mockResolvedValue(null)
})
async function page() { const wrapper = mount(LocationsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function field(dialog: Pick<VueWrapper, 'findAllComponents'>, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
function row(wrapper: VueWrapper, code = 'LOC-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(code)).at(-1)! }
async function open(action = '新增库位') { const wrapper = await page(); const button = action === '新增库位' ? wrapper.findAll('button').find(item => item.text() === action)! : row(wrapper).findAll('button').find(item => item.text() === action)!; await button.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
field(dialog, '海外仓').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '9007199254740994')
await field(dialog, '库位编码').get('input').setValue(' LOC-NEW ')
field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1234.567)
await field(dialog, '备注').get('input').setValue(' 测试备注 '); await flushPromises()
}
async function save(dialog: VueWrapper) { await dialog.get('.app-dialog__footer .el-button--primary').trigger('click'); await flushPromises() }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() }
describe('native overseas location dialogs', () => {
it('keeps the supported fields and exact creation payload through fullscreen', async () => {
const { dialog } = await open(); await fill(dialog)
expect(dialog.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).toEqual(['海外仓', '库位编码', '最大承重(kg)', '备注'])
expect(field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0.001, precision: 3 })
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(' LOC-NEW ')
await save(dialog)
expect(api.create).toHaveBeenCalledExactlyOnceWith('9007199254740994', { locationCode: 'LOC-NEW', locationType: 'NORMAL', maxLoadKg: 1234.567, status: 'ACTIVE', remark: '测试备注' })
expect(api.update).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(false)
})
it('requires the warehouse, code and a finite positive maximum load', async () => {
const { dialog } = await open(); await save(dialog); expect(api.create).not.toHaveBeenCalled()
await fill(dialog)
const input = field(dialog, '最大承重(kg)').getComponent({ name: 'ElInputNumber' })
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { input.vm.$emit('update:modelValue', value); dialog.vm.$emit('confirm'); await flushPromises() }
expect(api.create).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(true)
})
it('keeps an inactive location inactive when editing and preserves the original warehouse and version zero', async () => {
const { dialog } = await open('编辑')
expect(dialog.props('title')).toBe('编辑海外库位 · LOC-001')
expect(field(dialog, '海外仓').text()).toContain('洛杉矶仓(LAX)'); expect(field(dialog, '海外仓').findAll('input')).toHaveLength(0)
await field(dialog, '库位编码').get('input').setValue(' LOC-EDIT '); await save(dialog)
expect(api.update).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { locationCode: 'LOC-EDIT', locationType: 'NORMAL', maxLoadKg: 1000, status: 'INACTIVE', remark: '原备注', version: 0 })
expect(api.create).not.toHaveBeenCalled(); expect(api.status).not.toHaveBeenCalled()
})
it('locks repeated saves and closing, retaining inputs and diagnostics after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open(); await fill(dialog); await save(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '库位编码').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('.app-dialog__footer .el-button').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('保存未确认', 'A0503', 503, null, 'location-create-request')); await flushPromises()
expect(dialog.text()).toContain('location-create-request'); expect(dialog.findComponent(LoadErrorState).props('showRetry')).toBe(false)
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(' LOC-NEW ')
const originalPayload = api.create.mock.calls[0]![1]; await save(dialog); expect(api.create.mock.calls[1]![1]).toEqual(originalPayload)
})
it('shows candidate failure inside the dialog and locks the original reload', async () => {
api.warehouses.mockRejectedValueOnce(new ApiResponseError('候选失败', 'A0503', 503, null, 'location-warehouse-request'))
const { dialog } = await open(); expect(dialog.text()).toContain('location-warehouse-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
let resolve!: (value: { records: Array<{ id: string; warehouseName: string; warehouseCode: string }> }) => void
api.warehouses.mockReturnValueOnce(new Promise(done => { resolve = done }))
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] }); await flushPromises()
expect(api.warehouses).toHaveBeenCalledTimes(2); expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
await fill(dialog); await save(dialog); expect(api.create).toHaveBeenCalledOnce()
})
it.each([['ACTIVE', '停用', 'INACTIVE'], ['INACTIVE', '启用', 'ACTIVE']] as const)('confirms %s with its business code and sends the original status command', async (current, action, next) => {
api.all.mockResolvedValueOnce({ records: [location({ status: current })] })
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === action)!.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('LOC-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
expect(api.status).not.toHaveBeenCalled(); await accept()
expect(api.status).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { locationCode: 'LOC-001', locationType: 'NORMAL', maxLoadKg: 1000, status: next, remark: '原备注', version: 0 })
})
it('deletes only after the native confirmation and preserves the warehouse, long id and version', async () => {
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('LOC-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
expect(api.remove).not.toHaveBeenCalled(); await accept()
expect(api.remove).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', 0)
})
it('protects A0, virtual, shared overflow and occupied resources while retaining valid normal actions', async () => {
const records = [
location({ id: '1', locationCode: 'A0' }),
location({ id: '2', locationCode: 'SHARED-01', locationType: 'SHARED_OVERFLOW' }),
location({ id: '3', locationCode: 'VIRTUAL-01', locationType: 'VIRTUAL' }),
location({ id: '4', locationCode: 'OVERFLOW-FLAG', sharedOverflow: true }),
location({ id: '5', locationCode: 'BUSY-01', status: 'OCCUPIED', occupiedPalletCount: 1 }),
location({ id: '6', locationCode: 'BOUND-01', occupiedPalletCount: 1 }),
location({ id: '7', locationCode: 'IDLE-01', status: 'IDLE' }),
location(),
]
api.all.mockResolvedValueOnce({ records }); const wrapper = await page()
for (const code of ['A0', 'SHARED-01', 'VIRTUAL-01', 'OVERFLOW-FLAG', 'BUSY-01', 'IDLE-01']) expect(row(wrapper, code).findAll('button')).toHaveLength(0)
expect(row(wrapper, 'BOUND-01').findAll('button').map(button => button.text())).toEqual(['编辑', '启用'])
expect(row(wrapper).findAll('button').map(button => button.text())).toEqual(['编辑', '启用', '删除'])
})
it('locks competing actions until the status request finishes and shows diagnostics on failure', async () => {
let reject!: (error: Error) => void
api.status.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '启用')!.trigger('click'); await flushPromises(); await accept()
for (const button of row(wrapper).findAll('button')) expect(button.attributes('disabled')).toBeDefined()
await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); expect(api.remove).not.toHaveBeenCalled()
reject(new ApiResponseError('状态保存失败', 'A0503', 503, null, 'location-status-request')); await flushPromises()
expect(wrapper.text()).toContain('location-status-request'); expect(api.status).toHaveBeenCalledOnce()
expect(row(wrapper).findAll('button').every(button => button.attributes('disabled') === undefined)).toBe(true)
})
it('cancels without deleting and keeps list-only and separate write permissions', async () => {
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '删除')!.trigger('click'); await flushPromises()
await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises(); expect(api.remove).not.toHaveBeenCalled()
can.mockImplementation(code => code === permission.locationList)
const readonly = await page(); expect(row(readonly).findAll('button')).toHaveLength(0)
expect(readonly.findAll('button').some(button => button.text() === '新增库位')).toBe(false)
can.mockImplementation(code => code !== permission.locationEdit && code !== permission.locationDelete)
const createOnly = await open(); await fill(createOnly.dialog)
expect(row(createOnly.wrapper).findAll('button')).toHaveLength(0)
can.mockReturnValue(false); createOnly.dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
})
it('keeps inactive filtering in the current server vocabulary and resets a cancelled form', async () => {
const wrapper = await page(); const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').find((item: { key: string }) => item.key === 'status')!.options).toContainEqual({ label: '已停用', value: 'INACTIVE' })
toolbar.vm.$emit('search', { warehouseId: '9007199254740994', locationCode: '', locationType: '', status: 'INACTIVE' }); await flushPromises()
expect(api.list).toHaveBeenLastCalledWith('9007199254740994', { page: 1, size: 200, keyword: undefined, locationType: '', status: 'INACTIVE' })
await wrapper.findAll('button').find(item => item.text() === '新增库位')!.trigger('click'); await flushPromises(); const dialog = wrapper.findComponent(AppDialog)
await fill(dialog); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(item => item.text() === '新增库位')!.trigger('click'); await flushPromises()
expect((field(dialog, '库位编码').get('input').element as HTMLInputElement).value).toBe(''); expect(api.create).not.toHaveBeenCalled()
})
})
@@ -2,28 +2,35 @@
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Save } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listAllOverseasLocations, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<{ message: string } | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ warehouseId: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
const form = reactive({ warehouseId: '', locationCode: '', locationName: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const form = reactive({ warehouseId: '', locationCode: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
const contextError = ref<ApiErrorDetails | null>(null)
const dialogError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const pendingAction = ref('')
const editorBusy = computed(() => saving.value || contextLoading.value)
const formLocked = computed(() => editorBusy.value || Boolean(contextError.value) || !access.can(editing.value ? permission.locationEdit : permission.locationCreate))
const queryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'DISABLED' }] },
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'INACTIVE' }] },
])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
@@ -32,25 +39,103 @@ const warehouseNameById = computed(() => new Map(warehouses.value.map(item => [S
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.locationType === 'NORMAL' && row.status === 'INACTIVE' && Number(row.occupiedPalletCount || 0) === 0 }
function actionError(error: unknown, fallback: string) { mutationError.value = getApiErrorDetails(error, fallback) }
async function loadWarehouses() {
if (contextLoading.value) return
contextLoading.value = true; contextError.value = null
try { warehouses.value = (await listReceivingOverseasWarehouses()).records; await load() }
catch (error) { contextError.value = getApiErrorDetails(error, '海外仓列表加载失败') }
finally { contextLoading.value = false }
}
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); if (!warehouses.value.length) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const selectedWarehouseId = String(filters.value.warehouseId || ''); const result = await (selectedWarehouseId ? listOverseasLocations(selectedWarehouseId, { page: 1, size: 200, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' }) : listAllOverseasLocations(warehouses.value.map(item => String(item.id)), { keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: String(filters.value.status || '') as OverseasLocationStatus | '' })); const records = result.records.map(row => ({ ...row, warehouseName: row.warehouseName || warehouseNameById.value.get(String(row.warehouseId)) || null })); total.value = records.length; const start = (page.value - 1) * pageSize.value; rows.value = records.slice(start, start + pageSize.value) } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() { resetForm(); editorOpen.value = true }
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
async function save() { if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库位编码和最大承重'); return }; saving.value = true; try { const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), locationCode: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
function openCreate() {
if (editorOpen.value || editorBusy.value || pendingAction.value || !access.can(permission.locationCreate)) return
resetForm(); dialogError.value = null; mutationError.value = null; editorOpen.value = true
}
function openEdit(row: OverseasLocationDto) {
if (editorOpen.value || editorBusy.value || pendingAction.value || !canEdit(row)) return
Object.assign(form, { warehouseId: row.warehouseId, locationCode: row.locationCode, locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE', remark: row.remark || '' })
editing.value = row; dialogError.value = null; mutationError.value = null; editorOpen.value = true
}
async function save() {
if (!editorOpen.value || formLocked.value || pendingAction.value || (editing.value ? !canEdit(editing.value) : !access.can(permission.locationCreate))) return
if (!form.warehouseId || !form.locationCode.trim() || form.maxWeightKg == null || !Number.isFinite(form.maxWeightKg) || form.maxWeightKg <= 0) { ElMessage.warning('请填写海外仓、库位编码和大于0的最大承重'); return }
saving.value = true; dialogError.value = null
try {
const payload = { locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }
if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version })
else await createOverseasLocation(form.warehouseId, payload)
editorOpen.value = false; ElMessage.success('库位已保存'); await load()
} catch (error) { dialogError.value = getApiErrorDetails(error, '保存库位失败') }
finally { saving.value = false }
}
async function toggleStatus(row: OverseasLocationDto) {
if (!canStatus(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认', { customClass: 'admin-density-confirm', type: next === 'ACTIVE' ? 'info' : 'warning' })
await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version })
ElMessage.success(next === 'ACTIVE' ? '库位已启用' : '库位已停用'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') }
finally { pendingAction.value = '' }
}
async function remove(row: OverseasLocationDto) {
if (!canDelete(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning', customClass: 'admin-density-confirm' })
await deleteOverseasLocation(row.warehouseId, row.id, row.version); ElMessage.success('库位已删除'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') }
finally { pendingAction.value = '' }
}
onMounted(() => { void loadWarehouses() })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><section><DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" :disabled="Boolean(editing)" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" placeholder="请输入库位名称(选填)" /></el-form-item><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
<template>
<AdminPage class="admin-density">
<ListPageLayout aria-label="海外仓库位管理">
<section>
<DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError && !contextError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices>
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
<LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="mutationError" title="库位操作失败" :message="mutationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
</template>
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
<template #actions="{ row }">
<div class="density-actions row-actions admin-text-actions">
<el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="openEdit(row as OverseasLocationDto)">编辑</el-button>
<el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" :disabled="editorOpen || editorBusy || Boolean(pendingAction)" @click="remove(row as OverseasLocationDto)">删除</el-button>
</div>
</template>
<template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template>
<template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template>
<template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 个库位</template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editing ? `编辑海外库位 · ${editing.locationCode}` : '新增海外库位'" width="min(560px, calc(100vw - 28px))" fullscreenable :loading="contextLoading" :confirm-loading="editorBusy" :confirm-disabled="formLocked" confirm-text="保存库位" @confirm="save">
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" title="保存库位失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="formLocked">
<section class="density-dialog-section" data-tone="work">
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="海外仓" required><span v-if="editing">{{ selectedWarehouse ? `${selectedWarehouse.warehouseName}(${selectedWarehouse.warehouseCode})` : '—' }}</span><el-select v-else v-model="form.warehouseId" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item></td></tr>
<tr><td><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -0,0 +1,147 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { OneClickOutboundCandidate } from '../api/outbounds'
const api = vi.hoisted(() => ({ list: vi.fn(), candidates: vi.fn(), create: vi.fn(), returns: vi.fn(), createReturn: vi.fn(), transfers: vi.fn(), bind: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), tenants: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.tenants }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
vi.mock('../api/outbounds', () => ({ listOutbounds: api.list, listOneClickOutboundCandidates: api.candidates, createOneClickDropshipOutbound: api.create, listReturnOutboundCandidates: api.returns, createReturnOutbound: api.createReturn, listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bind, cancelOutbound: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const candidate = (index = 0): OneClickOutboundCandidate => ({ inventoryPositionId: '9007199254740993' + index, customerId: 'C1', customerName: '用于验证长文本省略的完整客户名称', orderNo: 'ORDER-' + index, inboundCode: 'IN-' + index, overseasWarehouseId: 'W1', palletNo: 'PALLET-' + index, skuCode: 'SKU-' + index, productName: '中文品名', availablePackages: index + 2, grossWeightKg: 20, volumeWeightKg: 30, volumeCbm: 0.2 })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [], total: 0 })
api.candidates.mockResolvedValue([candidate()]); api.create.mockResolvedValue({ outboundNo: 'OUT-1' }); api.createReturn.mockResolvedValue({ outboundNo: 'OUT-2' }); api.bind.mockResolvedValue([])
api.tenants.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'FBA1', warehouseName: 'FBA目的仓', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' }] })
api.transfers.mockResolvedValue([]); api.returns.mockResolvedValue([{ returnNo: 'RT-1', cartonMark: 'RC-1', palletCode: 'P-1' }])
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 6 }], total: 1 })
api.cartons.mockResolvedValue({ records: [{ cartonMark: 'RC-1', sourceReturnNo: 'RT-1', sourceUnitType: 'BOX', status: 'AVAILABLE' }], total: 1 })
api.carton.mockResolvedValue({ cartonMark: 'RC-1', items: [{ skuCode: 'SKU-A', quantity: 4 }, { skuCode: 'SKU-B', quantity: 2 }] })
})
async function render(open = true) {
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }); await flushPromises()
if (open) { await wrapper.findAll('button').find(button => button.text() === '新建出库单')!.trigger('click'); await flushPromises() }
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
}
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function chooseMode(dialog: VueWrapper, value: string) { const control = field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function choose(dialog: VueWrapper, label: string, value: string) { const control = field(dialog, label).getComponent({ name: 'ElSelect' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function selectFirst(dialog: VueWrapper) { await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); await flushPromises() }
describe('native outbound creation dialogs', () => {
it('keeps continuous scope controls, full candidate facts, paging and fullscreen input', async () => {
const { dialog } = await render()
expect(dialog.props('width')).toBe('min(1760px, calc(100vw - 32px))')
expect(dialog.get('.create-outbound-form').attributes('class')).toContain('density-dialog-sections')
expect(dialog.get('.dropship-search-row').findAll('.el-form-item')).toHaveLength(2)
const table = dialog.findComponent({ name: 'ElTable' })
expect(table.props('maxHeight')).toBeUndefined()
expect(table.findAllComponents({ name: 'ElTableColumn' }).find(column => column.props('prop') === 'customerName')!.props('showOverflowTooltip')).toBe(true)
expect(table.text()).toContain('可出毛重(kg)'); expect(table.text()).toContain('可出材积重(kg)'); expect(table.text()).toContain('可出体积(m³)')
await field(dialog, '备注').get('textarea').setValue('本次出库备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('本次出库备注')
await selectFirst(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '一件代发订单直出', items: [{ inventoryPositionId: candidate().inventoryPositionId, packages: 2, sku: 'SKU-0', cartonMarks: [] }], remark: '本次出库备注', idempotencyKey: expect.stringMatching(/^web-dropship-/) })
})
it('locks editing, closing and submission while candidates are loading', async () => {
let resolve!: (value: OneClickOutboundCandidate[]) => void
api.candidates.mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '海外仓').getComponent({ name: 'ElSelect' }).props('disabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
resolve([candidate()]); await flushPromises(); expect(dialog.props('confirmLoading')).toBe(false)
})
it('prevents duplicate creation and retains failed inputs, selection and diagnostics', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValue(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await render(); await selectFirst(dialog)
await field(dialog, '备注').get('textarea').setValue('失败后保留')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
reject(new ApiResponseError('创建失败', 'A0503', 503, null, 'outbound-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true); expect(dialog.text()).toContain('outbound-request')
expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('失败后保留')
})
it('starts a new form after cancel without leaking recipient, remark or return mode', async () => {
const { wrapper, dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND')
await field(dialog, '收件信息').get('input').setValue('上一单收件信息')
await field(dialog, '备注').get('textarea').setValue('上一单备注')
const mode = field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }); mode.vm.$emit('update:modelValue', 'PALLET'); mode.vm.$emit('change', 'PALLET'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新建出库单')!.trigger('click'); await flushPromises()
expect(field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('ONE_CLICK_DROPSHIP')
await chooseMode(dialog, 'RETURN_OUTBOUND')
expect((field(dialog, '收件信息').get('input').element as HTMLInputElement).value).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('')
expect(field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('BOX')
})
it('keeps whole multi-SKU boxes and original return scope without partial quantity inputs', async () => {
const { dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND'); await choose(dialog, '客户', 'C1'); await choose(dialog, '退货单', 'RT-1')
await field(dialog, '收件信息').get('input').setValue('本次收件信息')
expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
const cards = dialog.findAll('.return-list-card'); expect(cards).toHaveLength(2)
expect(cards[1]!.text()).toContain('SKU-A × 4,SKU-B × 2')
for (const table of dialog.findAllComponents({ name: 'ElTable' })) expect(table.props('maxHeight')).toBeUndefined()
await cards[1]!.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createReturn).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '本次收件信息', overseasWarehouseId: 'W1', returnNo: 'RT-1', cartonMarks: ['RC-1'], unitType: 'BOX', remark: undefined, idempotencyKey: expect.stringMatching(/^web-return-/) })
})
it('binds grouped private destinations while hiding internal customer identifiers', async () => {
const source = { customerId: '90071992547409930', transferNo: 'WT-1', overseasWarehouseId: 'W1', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false, packageCount: 3 }
api.transfers.mockResolvedValue([source, { ...source, packageCount: 4 }])
const { dialog } = await render(); await chooseMode(dialog, 'WAREHOUSE_TRANSFER_OUTBOUND')
expect(dialog.text()).toContain('客户名称待补充'); expect(dialog.text()).not.toContain(source.customerId)
const table = dialog.findComponent({ name: 'ElTable' }); expect(table.props('data')).toMatchObject([{ transferNo: 'WT-1', packageCount: 7 }])
const type = field(dialog, '目的地类型').getComponent({ name: 'ElRadioGroup' }); type.vm.$emit('update:modelValue', 'PRIVATE'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.bind).not.toHaveBeenCalled()
for (const [label, value] of [['私人仓地址', '完整地址'], ['邮编', '90210'], ['联系人', '收件人'], ['联系电话', '测试电话']]) await field(dialog, label!).get('input').setValue(value!)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.bind).toHaveBeenCalledExactlyOnceWith({ transfers: [{ customerId: source.customerId, transferNo: 'WT-1' }], destinationType: 'PRIVATE', destinationWarehouseId: undefined, destinationWarehouseCode: undefined, destinationWarehouseName: undefined, privateDestinationAddress: '完整地址', privateDestinationPostalCode: '90210', privateDestinationContactName: '收件人', privateDestinationContactPhone: '测试电话', idempotencyKey: expect.stringMatching(/^web-transfer-destination-/) })
expect(api.create).not.toHaveBeenCalled(); expect(api.createReturn).not.toHaveBeenCalled()
})
it('shows candidate diagnostics, blocks submission and retries the same query', async () => {
api.candidates.mockRejectedValueOnce(new ApiResponseError('候选暂不可用', 'A0503', 503, null, 'candidate-request')).mockResolvedValueOnce([candidate()])
const { dialog } = await render(); expect(dialog.text()).toContain('candidate-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.candidates).toHaveBeenLastCalledWith(undefined, { warehouseId: 'W1' })
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.props('confirmDisabled')).toBe(false)
})
it('preserves scope and inputs when retrying a failed platform resource read', async () => {
api.tenants.mockRejectedValueOnce(new ApiResponseError('客户资料暂不可用', 'A0503', 503, null, 'resource-request'))
const { dialog } = await render(); expect(dialog.text()).toContain('resource-request')
await field(dialog, '备注').get('textarea').setValue('重读时保留'); await selectFirst(dialog)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('重读时保留')
})
it('keeps creation permission separate from list access', async () => {
can.mockImplementation(code => !code.endsWith(':create'))
const { wrapper, dialog } = await render(false)
expect(wrapper.findAll('button').some(button => button.text() === '新建出库单')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.candidates).not.toHaveBeenCalled(); expect(api.create).not.toHaveBeenCalled()
})
})
@@ -46,6 +46,8 @@ const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
const resourceErrors = ref<ApiErrorDetails[]>([]), candidateError = ref<ApiErrorDetails | null>(null), createError = ref<ApiErrorDetails | null>(null)
const createLocked = computed(() => saving.value || inventoryLoading.value || !access.can(permission.outboundCreate))
const customerOptions = computed(() => {
const customers = new Map<string, string>()
tenantOptions.value.forEach((item) => {
@@ -65,7 +67,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
const snapshotName = row.customerName?.trim()
if (snapshotName) return snapshotName
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '客户名称待补充'
}
const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
@@ -115,7 +117,7 @@ async function loadCustomerOptions() {
}
}
async function loadInventoryOptions() {
inventoryLoading.value = true
inventoryLoading.value = true; resourceErrors.value = []
try {
const [tenants, warehouses, fbaWarehouses] = await Promise.allSettled([
loadCustomerOptions(),
@@ -123,25 +125,25 @@ async function loadInventoryOptions() {
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'FBA', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
else resourceErrors.value.push(getApiErrorDetails(tenants.reason, '客户列表加载失败'))
if (warehouses.status === 'fulfilled') {
warehouseOptionsSource.value = warehouses.value.records
if (!selectedWarehouseId.value) {
const lax = warehouses.value.records.find((item) => item.warehouseCode === 'US-LAX-01' || item.warehouseName?.includes('洛杉矶'))
selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '')
}
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
} else resourceErrors.value.push(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败'))
if (fbaWarehouses.status === 'fulfilled') fbaWarehouseOptionsSource.value = fbaWarehouses.value.records
else ElMessage.error(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败').message)
else resourceErrors.value.push(getApiErrorDetails(fbaWarehouses.reason, 'FBA目的仓加载失败'))
} finally { inventoryLoading.value = false }
}
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function openCreate() { if (createOpen.value || saving.value || inventoryLoading.value || !access.can(permission.outboundCreate)) return; createOpen.value = true; createError.value = null; candidateError.value = null; recipient.value = ''; remark.value = ''; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnUnitType.value = 'BOX'; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { candidateError.value = null; if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; candidateError.value = getApiErrorDetails(cause, '一件代发订单加载失败') } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function selectCustomer() { candidateError.value = null; selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { candidateError.value = null; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function loadTransferCandidates() {
inventoryLoading.value = true
candidateError.value = null; inventoryLoading.value = true
try {
const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
@@ -162,7 +164,7 @@ async function loadTransferCandidates() {
} catch (cause) {
transferCandidates.value = []
selectedTransferPalletIds.value = []
ElMessage.error(getApiErrorDetails(cause, '无目的地仓储转运单加载失败').message)
candidateError.value = getApiErrorDetails(cause, '无目的地仓储转运单加载失败')
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
@@ -180,7 +182,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
}
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
async function loadReturnCandidates() { candidateError.value = null; if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { candidateError.value = getApiErrorDetails(cause, '客户库存加载失败') } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
@@ -203,7 +205,19 @@ function toggleReturnPallet(palletCode: string, checked: boolean | string | numb
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark))
}
async function retryCreateCandidates() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates()
else if (outboundType.value === 'RETURN_OUTBOUND') await loadReturnCandidates()
else await loadTransferCandidates()
}
async function retryCreateResources() {
if (!createOpen.value || saving.value || inventoryLoading.value) return
await loadInventoryOptions(); await retryCreateCandidates()
}
async function submit() {
if (!createOpen.value || createLocked.value || candidateError.value) return
createError.value = null
if (outboundType.value !== 'WAREHOUSE_TRANSFER_OUTBOUND' && !selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }
if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }
saving.value = true
@@ -237,7 +251,7 @@ async function submit() {
ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`)
}
createOpen.value = false; await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false }
} catch (cause) { createError.value = getApiErrorDetails(cause, '出库操作未完成') } finally { saving.value = false }
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
@@ -277,46 +291,43 @@ onMounted(load)
<template #notices><template v-if="error"><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" :disabled="createOpen || saving || inventoryLoading" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button><el-button v-if="row.dispatchNo && access.can(permission.dispatchList)" class="density-action" data-tone="primary" size="small" @click="openDispatch(row as OutboundRow)">{{ row.status === 'CANCELLED' ? '处理原排车' : '查看排车' }}</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving || inventoryLoading" :confirm-disabled="createLocked || Boolean(candidateError)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-for="(details, index) in resourceErrors" :key="index" title="出库基础资料读取失败" :message="details.message" :loading="inventoryLoading || saving" @retry="retryCreateResources"><template #details><ApiErrorMeta :details="details" /></template></LoadErrorState>
<LoadErrorState v-if="candidateError" title="出库候选读取失败" :message="candidateError.message" :loading="inventoryLoading || saving" @retry="retryCreateCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState>
<LoadErrorState v-if="createError" title="出库操作未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<el-alert v-if="!access.can(permission.outboundCreate)" title="当前账号没有新建出库权限。" type="warning" :closable="false" show-icon />
<el-form class="create-outbound-form density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="createLocked">
<section class="density-dialog-section" data-tone="work" aria-label="出库模式"><h3 class="density-dialog-section__title">出库类型</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
<el-radio-button label="WAREHOUSE_TRANSFER_OUTBOUND">仓储转运出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<div class="dropship-search-row">
<el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
</el-form-item></td></tr></tbody></table></section>
<template v-if="outboundType === 'ONE_CLICK_DROPSHIP'">
<section class="density-dialog-section" data-tone="work" aria-label="一件代发出库范围"><h3 class="density-dialog-section__title">出库范围</h3><table class="density-table density-table--editable outbound-editor-table dropship-search-row"><tbody><tr><td><el-form-item label="海外仓" required>
<el-select :disabled="createLocked" v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="一件代发订单">
</el-form-item></td><td><el-form-item label="一件代发订单">
<div class="candidate-query">
<el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button>
</div>
</el-form-item>
</div>
<section class="candidate-results" aria-label="已完成入库的一件代发订单">
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360">
<el-table-column label="选择" width="68" align="center">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="inventoryLoading || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-form-item></td></tr></tbody></table></section>
<section class="candidate-results density-dialog-section" data-tone="source" aria-label="已完成入库的一件代发订单"><h3 class="density-dialog-section__title">已完成入库的订单</h3>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left">
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="createLocked || Boolean(candidateError) || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-table-column>
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="orderNo" label="尾程订单号" min-width="170" show-overflow-tooltip />
@@ -329,96 +340,73 @@ onMounted(load)
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="density-dialog-note">暂无可直接出库的已完成入库订单</div>
<div class="candidate-pagination">
<span aria-live="polite" title="翻页保留选择">已选择 {{ selectedDropshipIds.length }} 条 · 第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
<el-pagination :disabled="createLocked" v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
</div>
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<p v-else class="candidate-hint">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
<p v-else class="density-dialog-note">按托盘与 SKU 选择,默认出库全部可用箱数;仅限同一客户、同一海外仓,翻页保留选择。</p>
</section>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<el-form-item label="无目的地转运单" required>
<el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table" max-height="380">
<el-table-column label="选择" width="68" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedTransferPalletIds.includes(transferSelectionKey(row as WarehouseTransferOutboundCandidate))" @change="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></template></el-table-column>
</template>
<template v-else-if="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND'">
<el-alert title="仅展示已完成入库、换标已完成或无需换标,并且尚未设置目的地的仓储转运单。绑定后将从此列表移除并进入智能排车候选。" type="info" :closable="false" />
<section class="density-dialog-section" data-tone="source" aria-label="无目的地转运单"><h3 class="density-dialog-section__title">无目的地转运单</h3><el-table v-loading="inventoryLoading" :data="transferCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择转运单 ' + row.transferNo" :model-value="selectedTransferPalletIds.includes(transferSelectionKey(row as WarehouseTransferOutboundCandidate))" @change="toggleTransferPallet(row as WarehouseTransferOutboundCandidate, $event)" /></template></el-table-column>
<el-table-column label="客户" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ transferCustomerName(row as WarehouseTransferOutboundCandidate) }}</template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" min-width="190" show-overflow-tooltip />
<el-table-column prop="packageCount" label="入库箱数" width="100" align="right" />
<el-table-column prop="volumeCbm" label="总体积(m³)" width="120" align="right" />
<el-table-column label="换标" width="110"><template #default="{ row }">{{ row.needRelabel ? `已换标${row.relabelNo ? ` · ${row.relabelNo}` : ''}` : '原箱出库' }}</template></el-table-column>
<el-table-column label="主单目的去向" min-width="240" show-overflow-tooltip><template #default="{ row }"><StatusBadge :status="transferHasDestination(row as WarehouseTransferOutboundCandidate) ? 'READY' : 'PENDING'" :label="transferDestinationLabel(row as WarehouseTransferOutboundCandidate)" /></template></el-table-column>
</el-table>
<div v-if="!inventoryLoading && !transferCandidates.length" class="form-hint">暂无已入库且尚无目的地的仓储转运单</div>
</el-form-item>
<el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="destinationType === 'FBA'" label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<template v-else>
<el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item>
<el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item>
<el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item>
</el-table><p v-if="!inventoryLoading && !transferCandidates.length" class="density-dialog-note">暂无已入库且尚无目的地的仓储转运单</p></section>
<section class="density-dialog-section" data-tone="work" aria-label="转运目的地登记"><h3 class="density-dialog-section__title">目的地</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="目的地类型" required><el-radio-group v-model="destinationType"><el-radio-button label="FBA">FBA目的仓</el-radio-button><el-radio-button label="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item></td></tr><tr v-if="destinationType === 'FBA'"><td colspan="2"><el-form-item label="FBA目的仓" required><el-select v-model="selectedDestinationWarehouseId" filterable clearable placeholder="请选择FBA目的仓"><el-option v-for="item in fbaWarehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><template v-else><tr><td><el-form-item label="私人仓地址" required><el-input v-model="deliveryAddress" placeholder="请输入完整派送地址" /></el-form-item></td><td><el-form-item label="邮编" required><el-input v-model="postalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></template></tbody></table></section>
</template>
</template>
<template v-else>
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
<p class="candidate-hint">整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p>
<el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<template v-else>
<el-alert title="整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="退货出库资料"><h3 class="density-dialog-section__title">退货出库资料</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td><el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item></td><td><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="createLocked || !selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item></td><td><el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item></td></tr></tbody></table></section>
<section v-if="returnUnitType === 'PALLET' && returnPallets.length" class="density-dialog-section" data-tone="work" aria-label="整托选择"><h3 class="density-dialog-section__title">整托选择</h3><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :disabled="createLocked || Boolean(candidateError)" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></section>
<div class="return-lists">
<section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div>
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">SKU库存</h3>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination :disabled="createLocked" v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
</section>
<section class="return-list-card">
<div class="list-title"><span>包裹 / 箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<section class="return-list-card density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">包裹 / 箱号列表</h3><p class="density-dialog-note">支持多个箱号或 SKU,用英文逗号分隔。</p>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :disabled="createLocked || Boolean(candidateError)" :aria-label="'选择箱号 ' + row.cartonMark" :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230" show-overflow-tooltip><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination :disabled="createLocked" v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section>
</div>
</template>
<el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</template>
<section class="density-dialog-section" data-tone="work" aria-label="本次出库备注"><h3 class="density-dialog-section__title">备注</h3><table class="density-table density-table--editable outbound-editor-table "><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.create-outbound-form { padding: 0 var(--admin-space-1); }
.create-outbound-form :deep(.el-form-item__content > .el-input), .create-outbound-form :deep(.el-form-item__content > .el-select), .create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }
.form-hint { margin-top: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-list { display: grid; gap: var(--admin-space-3); max-height: 300px; overflow: auto; padding: var(--admin-space-3) 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: var(--admin-space-5); align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: var(--admin-font-caption); }
.candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.dropship-search-row { display: grid; grid-template-columns: minmax(0, 280px) minmax(0, 640px); align-items: end; gap: var(--admin-space-5); }
.dropship-search-row :deep(.el-form-item) { min-width: 0; margin-bottom: var(--admin-space-3); }
.outbound-editor-table { table-layout: fixed; }
.outbound-editor-table td:not([colspan]) { width: 50%; }
.candidate-query { display: flex; width: 100%; min-width: 0; gap: var(--admin-space-3); }
.candidate-query > .el-input { flex: 1; min-width: 0; }
.candidate-query > .el-button { flex: 0 0 auto; }
.candidate-results { min-width: 0; }
.candidate-hint { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.5; }
.dropship-candidate-table { width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-pagination, .return-pagination { flex-wrap: wrap; }
.candidate-pagination, .return-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-4); flex-wrap: wrap; padding-top: var(--admin-space-4); }
.candidate-pagination > span, .return-pagination > span { flex-shrink: 0; white-space: nowrap; }
.candidate-pagination :deep(.el-pagination), .return-pagination :deep(.el-pagination) { min-width: 0; max-width: 100%; flex-wrap: wrap; gap: var(--admin-space-2); justify-content: flex-end; }
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.list-title span { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
.return-result-table { width: 100%; margin-top: var(--admin-space-5); background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding-top: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-4); }
.return-list-card { min-width: 0; margin: 0; }
.return-result-table { width: 100%; margin-top: var(--admin-space-4); }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>
@@ -0,0 +1,138 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletDto } from '../api/pallets'
import PalletsView from './PalletsView.vue'
const api = vi.hoisted(() => ({ warehouses: vi.fn(), fba: vi.fn(), list: vi.fn(), summary: vi.fn(), detail: vi.fn(), create: vi.fn(), update: vi.fn(), print: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: api.fba }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: api.warehouses, listPallets: api.list, getPalletSummary: api.summary, getPallet: api.detail, createPallet: api.create, updatePallet: api.update, getPalletLabelPdf: api.print }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
function pallet(): PalletDto { return { id: '9007199254740993', customerId: '0', warehouseId: '9007199254740994', palletCode: 'PLT-LAX-001', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: '原备注', version: 0 } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.warehouses.mockResolvedValue({ records: [{ id: '9007199254740994', warehouseName: '洛杉矶仓', warehouseCode: 'LAX' }] })
api.fba.mockResolvedValue([{ id: 'F1', warehouseName: 'ABE3仓', warehouseCode: 'ABE3' }])
api.list.mockResolvedValue({ records: [pallet()], total: 1 })
api.summary.mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0, scrappedCount: 0 })
api.detail.mockImplementation(async () => pallet()); api.create.mockResolvedValue('9007199254740995'); api.update.mockResolvedValue(null)
})
function field(dialog: Pick<VueWrapper, 'findAllComponents'>, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function page() { const wrapper = mount(PalletsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
async function open(action = '新增托盘') { const wrapper = await page(); await wrapper.findAll('button').filter(button => button.text() === action).at(-1)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
await field(dialog, '托盘编码').get('input').setValue(' PLT-NEW ')
for (const [label, value] of [['托盘类型', 'PLASTIC'], ['仓库', '9007199254740994'], ['所属 FBA 仓', 'ABE3'], ['状态', 'OCCUPIED']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2.3456)
field(dialog, '采购价格(USD)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34)
await field(dialog, '备注').get('textarea').setValue(' 新备注 '); await flushPromises()
}
async function confirm(dialog: VueWrapper) { await dialog.get('.app-dialog__footer .el-button--primary').trigger('click'); await flushPromises() }
describe('native overseas pallet dialogs', () => {
it('retains every field, precision and exact creation payload through fullscreen', async () => {
const { dialog } = await open(); await fill(dialog)
expect(dialog.findAllComponents({ name: 'ElFormItem' })).toHaveLength(8)
expect(field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0.0001, precision: 4 })
expect(field(dialog, '采购价格(USD)').getComponent({ name: 'ElInputNumber' }).props()).toMatchObject({ min: 0, precision: 2 })
expect(field(dialog, '托盘类型').findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE'])
expect(field(dialog, '状态').findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['IDLE', 'OCCUPIED', 'SCRAPPED'])
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe(' PLT-NEW ')
await confirm(dialog)
expect(api.create).toHaveBeenCalledExactlyOnceWith('9007199254740994', { palletCode: 'PLT-NEW', palletType: 'PLASTIC', fbaWarehouseCode: 'ABE3', capacityCbm: 2.3456, unitPrice: 12.34, status: 'OCCUPIED', locationId: null, remark: '新备注' })
expect(api.update).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(false)
})
it('keeps required fields and positive capacity validation before creating', async () => {
const { dialog } = await open(); await confirm(dialog); expect(api.create).not.toHaveBeenCalled()
await fill(dialog)
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await confirm(dialog)
expect(api.create).not.toHaveBeenCalled()
field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
field(dialog, '所属 FBA 仓').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', ''); await confirm(dialog)
expect(api.create).not.toHaveBeenCalled(); expect(dialog.props('modelValue')).toBe(true)
})
it('locks duplicate creation and closing, then retains the original fields after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open(); await fill(dialog)
await confirm(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '托盘编码').get('input').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('.app-dialog__footer .el-button').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('托盘保存未确认', 'A0503', 503, null, 'pallet-create-request')); await flushPromises()
expect(dialog.text()).toContain('pallet-create-request'); expect(dialog.findComponent(LoadErrorState).props('showRetry')).toBe(false)
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe(' PLT-NEW ')
const originalPayload = api.create.mock.calls[0]![1]
await confirm(dialog); expect(api.create.mock.calls[1]![1]).toEqual(originalPayload)
})
it('shows the edit business code while reading and retries the same warehouse and long id', async () => {
let reject!: (error: Error) => void
api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { wrapper, dialog } = await open('编辑')
expect(dialog.props('title')).toBe('编辑托盘 · PLT-LAX-001'); expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.findAll('input')).toHaveLength(0)
expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.update).not.toHaveBeenCalled()
reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'pallet-read-request')); await flushPromises()
expect(dialog.text()).toContain('pallet-read-request'); expect(dialog.props('confirmDisabled')).toBe(true)
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.detail.mock.calls).toEqual([['9007199254740994', '9007199254740993'], ['9007199254740994', '9007199254740993']])
expect(field(dialog, '仓库').text()).toContain('洛杉矶仓(LAX)'); expect(field(dialog, '仓库').findAll('input')).toHaveLength(0)
})
it('keeps version zero, immutable warehouse and editable facts after an update conflict', async () => {
api.update.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'pallet-conflict-request'))
const { dialog } = await open('编辑')
await field(dialog, '托盘编码').get('input').setValue(' PLT-EDIT ')
await field(dialog, '备注').get('textarea').setValue(' 修改备注 '); await confirm(dialog)
expect(api.update).toHaveBeenCalledExactlyOnceWith('9007199254740994', '9007199254740993', { palletCode: 'PLT-EDIT', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', locationId: null, remark: '修改备注', version: 0 })
expect(dialog.text()).toContain('pallet-conflict-request'); expect(dialog.props('modelValue')).toBe(true)
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe(' 修改备注 ')
expect(api.detail).toHaveBeenCalledOnce(); expect(api.create).not.toHaveBeenCalled()
})
it('shows context failures inside creation and locks submission through the original candidate reload', async () => {
api.fba.mockRejectedValueOnce(new ApiResponseError('FBA仓读取失败', 'A0503', 503, null, 'pallet-fba-request'))
const { dialog } = await open()
expect(dialog.text()).toContain('pallet-fba-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
let resolve!: (value: Array<{ id: string; warehouseName: string; warehouseCode: string }>) => void
api.fba.mockReturnValueOnce(new Promise(done => { resolve = done }))
await dialog.findComponent(LoadErrorState).get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
resolve([{ id: 'F1', warehouseName: 'ABE3仓', warehouseCode: 'ABE3' }]); await flushPromises()
expect(api.fba).toHaveBeenLastCalledWith({ warehouseType: 'FBA', status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenCalledTimes(2)
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); await fill(dialog); await confirm(dialog); expect(api.create).toHaveBeenCalledOnce()
})
it('keeps list-only access read-only and checks current permission again before saving', async () => {
can.mockImplementation(code => code === permission.palletList)
const wrapper = await page()
expect(wrapper.findAll('button').some(button => ['新增托盘', '编辑'].includes(button.text()))).toBe(false)
expect(wrapper.findAll('button').some(button => button.text() === '查看')).toBe(true)
expect(wrapper.findAll('button').some(button => button.text() === '打印')).toBe(true)
can.mockReturnValue(true)
const other = await open(); await fill(other.dialog)
can.mockReturnValue(false); other.dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).not.toHaveBeenCalled(); expect(api.update).not.toHaveBeenCalled()
})
it('resets a closed create form and leaves independent create and edit permissions intact', async () => {
can.mockImplementation(code => code !== permission.palletEdit)
const { wrapper, dialog } = await open(); await fill(dialog)
expect(wrapper.findAll('button').some(button => button.text() === '编辑')).toBe(false)
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新增托盘')!.trigger('click'); await flushPromises()
expect((field(dialog, '托盘编码').get('input').element as HTMLInputElement).value).toBe('')
expect(field(dialog, '容积(m³)').getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(2)
expect(api.create).not.toHaveBeenCalled(); expect(api.detail).not.toHaveBeenCalled()
})
})
@@ -51,6 +51,15 @@ const columns: DataTableColumn[] = [
]
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const detailLoading = ref(false)
const editReady = ref(false)
const editingPallet = ref<Pick<PalletListItem, 'id' | 'warehouseId' | 'code'> | null>(null)
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogErrorKind = ref<'read' | 'save'>('read')
const dialogBusy = computed(() => saving.value || detailLoading.value || contextLoading.value)
const dialogAllowed = computed(() => access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit))
const contextFailed = computed(() => Boolean(loadError.value && loadErrorKind.value === 'context'))
const formLocked = computed(() => dialogBusy.value || !dialogAllowed.value || contextFailed.value || (dialogMode.value === 'edit' && !editReady.value))
const form = reactive({ id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 })
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
@@ -96,27 +105,40 @@ function retryLoad() {
}
function openCreate() {
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletCreate)) return
dialogMode.value = 'create'; editingPallet.value = null; editReady.value = false; dialogError.value = null
Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '', version: 0 }); mutationError.value = null; dialogOpen.value = true
}
async function openEdit(source: object) {
const row = source as PalletListItem; saving.value = true; mutationError.value = null
if (dialogOpen.value || dialogBusy.value || !access.can(permission.palletEdit)) return
const row = source as PalletListItem
editingPallet.value = { id: row.id, warehouseId: row.warehouseId, code: row.code }
dialogMode.value = 'edit'; editReady.value = false; dialogError.value = null; mutationError.value = null; dialogOpen.value = true
await loadEdit()
}
async function loadEdit() {
const row = editingPallet.value
if (!dialogOpen.value || dialogMode.value !== 'edit' || !row || dialogBusy.value || !dialogAllowed.value) return
detailLoading.value = true; editReady.value = false; dialogError.value = null; dialogErrorKind.value = 'read'
try {
const detail = await getPallet(row.warehouseId, row.id)
dialogMode.value = 'edit'; Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); dialogOpen.value = true
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { saving.value = false }
Object.assign(form, { id: String(detail.id), warehouseId: String(detail.warehouseId), fbaWarehouseCode: detail.fbaWarehouseCode, code: detail.palletCode, type: detail.palletType, volume: number(detail.capacityCbm), purchasePrice: number(detail.unitPrice), status: detail.status, remark: detail.remark || '', version: detail.version }); editReady.value = true
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') }
finally { detailLoading.value = false }
}
async function save() {
if (!dialogOpen.value || formLocked.value || !access.can(dialogMode.value === 'create' ? permission.palletCreate : permission.palletEdit)) return
if (!form.warehouseId || !form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写仓库、托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
saving.value = true; mutationError.value = null
saving.value = true; dialogError.value = null; dialogErrorKind.value = 'save'
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try {
if (dialogMode.value === 'edit') await updatePallet(form.warehouseId, form.id, { ...payload, version: form.version })
else await createPallet(form.warehouseId, payload)
dialogOpen.value = false; ElMessage.success(dialogMode.value === 'edit' ? '托盘已更新' : '托盘已新增'); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
} catch (error) { dialogError.value = getApiErrorDetails(error, '托盘保存失败,请重试') }
finally { saving.value = false }
}
@@ -154,30 +176,35 @@ onMounted(initialize)
<template #notices><template v-if="loadError"><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="dialogOpen || dialogBusy" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" :disabled="dialogOpen || dialogBusy" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<el-form class="density-form" label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" placeholder="请输入托盘编码" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入容积" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" placeholder="请输入采购价格" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouseId" filterable :disabled="dialogMode === 'edit'" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status" placeholder="请选择托盘状态"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`" width="min(760px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="detailLoading || contextLoading" :confirm-loading="dialogBusy" :confirm-disabled="formLocked" @confirm="save">
<LoadErrorState v-if="contextFailed && loadError" title="托盘候选加载失败" :message="loadError.message" :loading="contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" :title="dialogErrorKind === 'read' ? '托盘详情加载失败' : '托盘保存失败'" :message="dialogError.message" :show-retry="dialogErrorKind === 'read'" :loading="detailLoading" @retry="loadEdit"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取托盘资料…</p>
<el-form v-if="dialogMode === 'create' || editReady" class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
<section class="density-dialog-section" data-tone="work" aria-label="托盘基础资料">
<h3 class="density-dialog-section__title">托盘资料</h3>
<table class="density-table density-table--editable pallet-editor-table"><tbody>
<tr><td><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" placeholder="请输入托盘编码" /></el-form-item></td><td><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入容积" /></el-form-item></td><td><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" placeholder="请输入采购价格" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item v-if="dialogMode === 'create'" label="仓库" required><el-select v-model="form.warehouseId" filterable placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item><el-form-item v-else label="仓库"><span>{{ warehouseName(form.warehouseId) }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="状态" required><el-select v-model="form.status" placeholder="请选择托盘状态"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.summary-grid span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number) { width: 100%; }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; border: 1px solid var(--cc-border); background: var(--admin-surface); }
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:#fff; flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--cc-border); }
@@ -0,0 +1,167 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import RelabelOrdersView from './RelabelOrdersView.vue'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { RelabelDetailDto } from '../api/relabelOrders'
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 })
api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null)
api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseCode: 'W-1', warehouseName: '海外收货仓', status: 'ACTIVE' }] })
api.pdf.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); api.upload.mockImplementation(async () => detail())
api.complete.mockResolvedValue(null); api.task.mockResolvedValue({ version: 0 }); api.rework.mockResolvedValue(null)
URL.createObjectURL = vi.fn().mockReturnValue('blob:relabel-pdf'); URL.revokeObjectURL = vi.fn()
})
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function fill(dialog: VueWrapper) {
for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!)
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises()
}
async function openDetail() { const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
async function chooseFile(input: ReturnType<VueWrapper['get']>, file: File) { Object.defineProperty(input.element, 'files', { value: [file], configurable: true }); await input.trigger('change'); await flushPromises() }
describe('native relabel operation dialogs', () => {
it('locks candidate loading and retains all fields through fullscreen creation', async () => {
let resolve!: (value: { records: Array<{ id: string; tenantName: string; status: string }>; pages: number }) => void
api.customers.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { dialog } = await openForm()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined()
resolve({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 }); await flushPromises()
expect(dialog.get('[aria-label="换标单生成方式"]').findAll('input')).toHaveLength(0)
await fill(dialog); await field(dialog, '备注').get('textarea').setValue(' 本次备注 ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] })
})
it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
const { dialog } = await openForm(); await fill(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined()
const key = api.create.mock.calls[0]![1].idempotencyKey
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises()
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key)
})
it('shows every original SKU as plain facts and requires a rejection reason', async () => {
const { dialog } = await openForm('审核')
expect(dialog.props('title')).toBe('审核换标单 · RL-1')
const facts = dialog.get('[aria-label="换标审核原始资料"]')
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
expect(facts.text()).toContain('SKU-A、SKU-B'); expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件')
const action = field(dialog, '审核动作').getComponent({ name: 'ElSelect' })
expect(action.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['PENDING_PROCESSING', 'REJECTED'])
action.vm.$emit('update:modelValue', 'REJECTED'); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
await field(dialog, '备注').get('textarea').setValue('驳回原因'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'REJECTED', '驳回原因')
})
it('shows independent resource failures and reloads each original candidate source', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'customer-request'))
api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'warehouse-request'))
const { dialog } = await openForm(); expect(dialog.text()).toContain('customer-request'); expect(dialog.text()).toContain('warehouse-request')
expect(dialog.props('confirmDisabled')).toBe(true); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
dialog.findAllComponents(LoadErrorState).find(item => item.props('title') === '客户候选读取失败')!.vm.$emit('retry'); await flushPromises()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenLastCalledWith()
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
})
it.each(['作废', '确认换标完成', '退回重新贴标'] as const)('keeps the original %s confirmation, business reference and command', async action => {
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
api.detail.mockResolvedValueOnce(review)
const { wrapper } = await openDetail()
await wrapper.findAll('button').find(button => button.text().includes(action))!.trigger('click'); await flushPromises()
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)
expect(element).toBeDefined(); const box = new DOMWrapper(element!)
expect(box.text()).toContain('RL-1'); expect(element!.classList.contains('admin-density-confirm')).toBe(true)
if (action === '退回重新贴标') await box.get('textarea').setValue(' 需要重新贴标 ')
await box.get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
if (action === '作废') expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'CANCELLED', '平台作废换标单')
else if (action === '确认换标完成') expect(api.complete).toHaveBeenCalledExactlyOnceWith('RL-1')
else expect(api.rework).toHaveBeenCalledExactlyOnceWith('RL-1', 0, '需要重新贴标', expect.any(String))
})
it('locks the competing review action until completion confirmation finishes', async () => {
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
api.detail.mockResolvedValueOnce(review)
let resolve!: (value: null) => void
api.complete.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await openDetail()
await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises()
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!
await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))!
expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises()
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled()
resolve(null); await flushPromises()
})
it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => {
can.mockImplementation(code => code.endsWith(':list'))
const { wrapper, dialog } = await openDetail()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0); expect(wrapper.text()).not.toContain('9007199254740999')
expect(wrapper.get('.relabel-mark-list table th').text()).toBe('操作(明细PDF)')
await wrapper.findAll('button').find(button => button.text().includes('直接预览'))!.trigger('click'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined)
expect(dialog.props('fullscreenable')).toBe(true); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
dialog.vm.$emit('update:modelValue', false); await flushPromises(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:relabel-pdf')
await wrapper.get('.relabel-mark-list').findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740995', 'ITEM', '9007199254740994')
})
it('discards closed preview responses and retries failed reads with the same file scope', async () => {
let resolve!: (value: Blob) => void
api.pdf.mockReturnValueOnce(new Promise(done => { resolve = done })).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'pdf-request'))
const { wrapper, dialog } = await openDetail(); const button = wrapper.findAll('button').find(item => item.text().includes('直接预览'))!
await button.trigger('click'); await flushPromises(); expect(dialog.props('loading')).toBe(true); expect(dialog.find('iframe').exists()).toBe(false)
dialog.vm.$emit('update:modelValue', false); await flushPromises(); resolve(new Blob(['late'], { type: 'application/pdf' })); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled()
await button.trigger('click'); await flushPromises(); expect(dialog.text()).toContain('pdf-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
})
it('uploads total and item PDFs through their original scope and blocks duplicate uploads', async () => {
const withoutItemFile = detail(); withoutItemFile.items[0]!.relabelFileId = null; withoutItemFile.items[0]!.relabelFileName = null
api.detail.mockResolvedValueOnce(withoutItemFile)
let resolve!: (value: RelabelDetailDto) => void
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(withoutItemFile)
const { wrapper } = await openDetail(); const file = new File(['pdf'], 'new.pdf', { type: 'application/pdf' })
const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
await chooseFile(input, file); expect(input.attributes('disabled')).toBeDefined()
await input.trigger('change'); await flushPromises(); expect(api.upload).toHaveBeenCalledExactlyOnceWith('RL-1', file, 'TOTAL', undefined)
resolve(withoutItemFile); await flushPromises()
await chooseFile(wrapper.get('.relabel-mark-list input[type="file"]'), file)
expect(api.upload).toHaveBeenLastCalledWith('RL-1', file, 'ITEM', '9007199254740994')
})
it('preserves PDF file checks and shows inline upload diagnostics without changing the current details', async () => {
api.upload.mockRejectedValueOnce(new ApiResponseError('上传失败', 'A0503', 503, null, 'upload-request'))
const { wrapper } = await openDetail(); const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
await chooseFile(input, new File(['png'], 'bad.png', { type: 'image/png' })); expect(api.upload).not.toHaveBeenCalled()
const tooBig = new File(['pdf'], 'large.pdf', { type: 'application/pdf' }); Object.defineProperty(tooBig, 'size', { value: 50 * 1024 * 1024 + 1 })
await chooseFile(input, tooBig); expect(api.upload).not.toHaveBeenCalled()
await chooseFile(input, new File(['pdf'], 'new.pdf', { type: 'application/pdf' }))
expect(wrapper.text()).toContain('upload-request'); expect(wrapper.text()).toContain('总换标.pdf'); expect(input.attributes('disabled')).toBeUndefined()
})
})
@@ -17,7 +17,8 @@ describe('RelabelOrderDetailView', () => {
it('shows the review state and customer-facing return order and FBA warehouse codes', () => {
expect(source).toContain("PENDING_REVIEW: '待审核'")
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('text(item.destinationFbaWarehouseCode)')
expect(source).not.toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('items.flatMap(relabelSkuCodes)')
expect(source).toContain('v-for="(sku, index) in item.skuItems"')
expect(source).toContain('{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, onBeforeUnmount } from 'vue'
import { computed, onActivated, onMounted, ref, onBeforeUnmount, watch } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,6 +14,7 @@ import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, up
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
@@ -36,28 +37,28 @@ async function voidOrder() {
if (voiding.value || confirming.value || returning.value || !canVoid.value || !access.can(permission.relabelOrderStatus)) return
voiding.value = true
try {
await ElMessageBox.confirm('确认作废当前换标单?作废后停止换标作业,保留原单和操作记录。', '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认作废?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await changeRelabelOrderStatus(orderId.value, 'CANCELLED', '平台作废换标单')
ElMessage.success('换标单已作废'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标单作废失败,请重试').message) }
finally { voiding.value = false }
}
async function confirmCompletion() {
if (confirming.value || voiding.value) return
if (confirming.value || returning.value || voiding.value) return
confirming.value = true
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认仓库上传的换标结果无误并继续后续流程吗?`, '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
finally { confirming.value = false }
}
async function returnForRework() {
if (returning.value || voiding.value) return
if (returning.value || confirming.value || voiding.value) return
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt('填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。', '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
const { value } = await ElMessageBox.prompt(`换标单 ${detail.value?.application.relabelNo || orderId.value}:填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。`, '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
await returnRelabelForRework(orderId.value, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回 PDA 重新贴标'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '退回失败,请刷新后重试').message) }
@@ -68,6 +69,16 @@ const uploading = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false), previewError = ref<ApiErrorDetails | null>(null), uploadError = ref<ApiErrorDetails | null>(null)
const previewRequest = ref<{ no: string; fileId: string; name: string; scope: 'TOTAL' | 'ITEM'; itemId?: string } | null>(null)
let previewGeneration = 0
function resetPreview() {
previewGeneration++; previewLoading.value = false; previewError.value = null; previewRequest.value = null
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
watch(previewOpen, visible => { if (!visible) resetPreview() })
watch(orderId, () => { previewOpen.value = false; resetPreview() })
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
@@ -142,21 +153,30 @@ async function load() {
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
onBeforeUnmount(resetPreview)
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
if (!file || !detail.value || uploading.value || !access.can(permission.relabelOrderCreate)) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { ElMessage.warning('只支持PDF文件'); return }
if (file.size > 50 * 1024 * 1024) { ElMessage.warning('PDF文件不能超过50MB'); return }
uploading.value = true
try { detail.value = await uploadRelabelPdf(orderId.value, file, scope, itemId); ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF上传失败').message) }
uploading.value = true; uploadError.value = null
const no = orderId.value
try { const result = await uploadRelabelPdf(no, file, scope, itemId); if (no === orderId.value) { detail.value = result; ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') } }
catch (error) { if (no === orderId.value) uploadError.value = getApiErrorDetails(error, 'PDF上传失败') }
finally { uploading.value = false }
}
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
if (!fileId) return
try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) }
const sequence = ++previewGeneration, no = orderId.value
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''; previewTitle.value = name; previewRequest.value = { no, fileId, name, scope, itemId }
previewOpen.value = true; previewLoading.value = true; previewError.value = null
try {
const blob = await downloadRelabelPdf(no, fileId, scope, itemId)
if (sequence !== previewGeneration || no !== orderId.value || !previewOpen.value) return
previewUrl.value = URL.createObjectURL(blob)
} catch (error) { if (sequence === previewGeneration && no === orderId.value && previewOpen.value) previewError.value = getApiErrorDetails(error, 'PDF预览加载失败') }
finally { if (sequence === previewGeneration) previewLoading.value = false }
}
async function previewCompletionPhoto(fileId: string) {
try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
@@ -169,17 +189,18 @@ async function previewCompletionPhoto(fileId: string) {
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="access.can(permission.relabelOrderStatus) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" :disabled="returning || voiding" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" :disabled="confirming || voiding" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label v-if="access.can(permission.relabelOrderCreate)" class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll">
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<thead><tr><th class="relabel-pdf-actions">操作(明细PDF)</th><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td><span v-if="item.relabelFileName" class="document-actions density-actions"><LineClampText :content="item.relabelFileName" /><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else-if="access.can(permission.relabelOrderCreate)" class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label><span v-else>—</span></td><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode) }}</td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -187,7 +208,7 @@ async function previewCompletionPhoto(fileId: string) {
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog class="admin-density-dialog" v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="(previewRequest?.no || orderId) + ' · ' + previewTitle" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-footer="false"><div class="density-dialog-sections"><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取换标PDF…</p><LoadErrorState v-if="previewError" title="换标PDF预览失败" :message="previewError.message" :loading="previewLoading" @retry="previewRequest && previewPdf(previewRequest.fileId, previewRequest.name, previewRequest.scope, previewRequest.itemId)"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div v-if="previewUrl" class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></div></AppDialog>
</template>
<style scoped>
@@ -203,8 +224,10 @@ table { min-width: 1120px; }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--cc-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:#f5f7fa; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
.pdf-preview { overflow: hidden; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; }
.relabel-pdf-actions { width: 220px; }
.document-actions > .line-clamp-text { max-width: 160px; }
.empty-cell { height: var(--admin-empty-height); color: var(--cc-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
@@ -52,9 +52,11 @@ describe('RelabelOrdersView', () => {
expect(wrapper.text()).toContain('SKU-A,SKU-B')
await wrapper.findAll('button').find(button => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-A、SKU-B')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-A × 10 件;NEW-B × 5 件')
expect(formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
const facts = wrapper.get('[aria-label="换标审核原始资料"]')
expect(facts.findAll('input, textarea')).toHaveLength(0)
expect(facts.text()).toContain('SKU-A、SKU-B')
expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件')
expect(facts.findAll('tr').find(row => row.text().startsWith('箱数'))!.get('td').text()).toBe('1')
})
it('renders product names and labeling requirements supplied by the list projection', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', productNameZh: '商品甲、商品乙', labelRequirement: '贴标要求111', status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -82,7 +84,7 @@ describe('RelabelOrdersView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
const customer = select(wrapper, '请选择 ACTIVE 客户')
const customer = select(wrapper, '请选择客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
@@ -99,10 +101,10 @@ describe('RelabelOrdersView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增换标单'))!.trigger('click')
await flushPromises()
const customer = select(wrapper, '请选择 ACTIVE 客户')
const customer = select(wrapper, '请选择客户')
customer.vm.$emit('update:modelValue', '10001'); customer.vm.$emit('change', '10001')
await flushPromises()
const warehouse = select(wrapper, '请选择 ACTIVE+RECEIVING 海外仓')
const warehouse = select(wrapper, '请选择公共海外收货仓')
warehouse.vm.$emit('update:modelValue', 'WH-1')
formItem(wrapper, '关联订单').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'CO-1')
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
@@ -196,10 +198,11 @@ describe('RelabelOrdersView', () => {
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-NEW')
expect(formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('中文商品')
expect(formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SHIP-9')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-SKU-9')
const facts = wrapper.get('[aria-label="换标审核原始资料"]')
expect(facts.text()).toContain('SKU-NEW')
expect(facts.text()).toContain('中文商品')
expect(facts.text()).toContain('SHIP-9')
expect(facts.text()).toContain('NEW-SKU-9')
})
it('passes the relabel order number search to the backend', async () => {
@@ -68,6 +68,8 @@ const detailLoading = ref(false)
const editSourceStatus = ref('')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('')
const dialogError = ref<ApiErrorDetails | null>(null), customersError = ref<ApiErrorDetails | null>(null), warehousesError = ref<ApiErrorDetails | null>(null)
const formLocked = computed(() => submitting.value || detailLoading.value || dialogMode.value === 'create' && (customersLoading.value || warehousesLoading.value) || !access.can(dialogMode.value === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus))
const selectedRows = ref<RelabelRow[]>([])
const allowedStatusOptions = computed(() => {
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
@@ -85,6 +87,9 @@ function openPackingPlan() {
}
async function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelRow | undefined
if (dialogOpen.value || submitting.value || detailLoading.value || !access.can(mode === 'create' ? permission.relabelOrderCreate : permission.relabelOrderStatus) || mode === 'edit' && (!row || !canAudit(row))) return
dialogError.value = null; customersError.value = null; warehousesError.value = null
dialogMode.value = mode
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
editSourceStatus.value = ''
@@ -123,7 +128,7 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
}
async function loadCustomers() {
customersLoading.value = true
customersError.value = null; customersLoading.value = true
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
@@ -135,11 +140,12 @@ async function loadCustomers() {
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) { customers.value = []; ElMessage.error(getApiErrorDetails(error, '客户列表加载失败,请重试').message) }
} catch (error) { customers.value = []; customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
finally { customersLoading.value = false }
}
async function loadWarehouses() {
warehousesError.value = null
form.warehouseId = ''
warehouses.value = []
warehousesLoading.value = true
@@ -147,14 +153,15 @@ async function loadWarehouses() {
const records = (await listRelabelWarehouseOptions()).records.filter((item) => item.status === 'ACTIVE')
warehouses.value = records
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '公共海外收货仓加载失败,请重试').message)
warehousesError.value = getApiErrorDetails(error, '公共海外收货仓加载失败,请重试')
} finally {
warehousesLoading.value = false
}
}
async function submitForm() {
if (submitting.value) return
if (!dialogOpen.value || formLocked.value || dialogMode.value === 'create' && (customersError.value || warehousesError.value)) return
dialogError.value = null
try {
if (dialogMode.value === 'create') {
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
@@ -175,7 +182,7 @@ async function submitForm() {
dialogOpen.value = false
createIdempotencyKey.value = ''
await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '操作失败,请重试').message) }
} catch (error) { dialogError.value = getApiErrorDetails(error, '操作失败,请重试') }
finally { submitting.value = false }
}
@@ -226,15 +233,19 @@ function canAudit(source: object) {
return row.reviewChannel !== 'TAIL_ORDER'
&& (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
}
let loadGeneration = 0
async function load(model: QueryModel = filters.value) {
const current = ++loadGeneration
Object.assign(filters.value, model)
loading.value = true; loadError.value = null
try {
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
quantities.value = null; quantityError.value = ''
const result = await listRelabelOrders(query)
try { quantities.value = await getRelabelQuantities(query) }
catch { quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计' }
const measured = await getRelabelQuantities(query).catch(() => null)
if (current !== loadGeneration) return
quantities.value = measured
if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
@@ -242,8 +253,8 @@ async function load(model: QueryModel = filters.value) {
createdAt: formatOverseasDateTime(item.createdAt),
}))
total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') }
finally { loading.value = false }
} catch (error) { if (current === loadGeneration) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') } }
finally { if (current === loadGeneration) loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
@@ -269,26 +280,28 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading" :confirm-disabled="detailLoading" @confirm="submitForm">
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单 · ' + (form.orderNo || '—')" width="min(760px, calc(100vw - 28px))" fullscreenable :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading || dialogMode === 'create' && (customersLoading || warehousesLoading)" :confirm-disabled="formLocked || dialogMode === 'create' && Boolean(customersError || warehousesError)" @confirm="submitForm">
<div class="density-dialog-sections">
<LoadErrorState v-if="dialogMode === 'create' && customersError" compact title="客户候选读取失败" :message="customersError.message" :loading="customersLoading || submitting" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogMode === 'create' && warehousesError" compact title="公共海外收货仓读取失败" :message="warehousesError.message" :loading="warehousesLoading || submitting" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehousesError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" compact title="换标操作未完成" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<el-form class="density-form density-dialog-sections" label-position="left" label-width="120px" :disabled="formLocked">
<template v-if="dialogMode === 'create'">
<section class="density-dialog-section" data-tone="summary" aria-label="换标单生成方式"><table class="density-table"><tbody><tr><th scope="row">换标单号</th><td>系统自动生成</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标基础资料"><h3 class="density-dialog-section__title">订单信息</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" placeholder="请输入关联订单号" /></el-form-item></td><td><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择公共海外收货仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></td></tr><tr><td><el-form-item label="商品SKU" required><el-input v-model="form.sku" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入商品SKU" /></el-form-item></td><td><el-form-item label="中文品名" required><el-input v-model="form.productName" placeholder="请输入中文品名" /></el-form-item></td></tr><tr><td><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></td><td><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></td></tr><tr><td><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></td><td><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标作业要求"><h3 class="density-dialog-section__title">作业要求</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></td><td><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" placeholder="请输入货件号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="请输入新SKU" /></el-form-item></td></tr></tbody></table></section>
</template>
<template v-else>
<section class="density-dialog-section" data-tone="source" aria-label="换标审核原始资料"><h3 class="density-dialog-section__title">原始资料</h3><table class="density-table"><tbody>
<tr><th scope="row">关联订单</th><td colspan="3">{{ form.relatedOrderNo || '—' }}</td></tr><tr><th scope="row">商品SKU</th><td colspan="3">{{ form.sku || '—' }}</td></tr><tr><th scope="row">中文品名</th><td colspan="3">{{ form.productName || '—' }}</td></tr>
<tr><th scope="row">箱数</th><td>{{ form.cartons ?? '—' }}</td><th scope="row">换标费用(CNY)</th><td>{{ form.relabelCost ?? '—' }}</td></tr>
<tr><th scope="row">贴标要求</th><td colspan="3">{{ form.labelRequirement || '—' }}</td></tr><tr><th scope="row">货件号</th><td colspan="3">{{ form.shipmentNo || '—' }}</td></tr><tr><th scope="row">新SKU</th><td colspan="3">{{ form.newSkuCode || '—' }}</td></tr>
</tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="换标审核结论"><h3 class="density-dialog-section__title">审核结论</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></td></tr></tbody></table></section>
</template>
<section class="density-dialog-section" data-tone="work" aria-label="换标备注与原因"><h3 class="density-dialog-section__title">{{ dialogMode === 'edit' ? '审核备注' : '备注' }}</h3><table class="density-table density-table--editable relabel-editor-table"><tbody><tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr></tbody></table><p v-if="dialogMode === 'edit'" class="density-dialog-note">审核驳回时必须填写原因。</p></section>
</el-form>
</div>
</AppDialog>
</AdminPage>
</template>
@@ -308,4 +321,7 @@ h3:not(:first-child) { margin-top: var(--admin-space-1); padding-top: var(--admi
.packing-item .el-input { flex: 1; }
.packing-item .el-input-number { width: 160px; }
.packing-dimensions .el-input-number { width: 150px; }
.relabel-editor-table { table-layout: fixed; }
.relabel-editor-table td:not([colspan]) { width: 50%; }
@media (max-width: 720px) { .relabel-editor-table, .relabel-editor-table tbody { display: block; }.relabel-editor-table tr { display: grid; grid-template-columns: 1fr; }.relabel-editor-table td:not([colspan]) { width: auto; } }
</style>
@@ -0,0 +1,179 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import type { ReturnCartonDetail, ReturnCartonLabel } from '../api/returnInventory'
import type { ReturnPackingPlanDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), warehouses: vi.fn(), stock: vi.fn(), cartons: vi.fn(), movements: vi.fn(), sources: vi.fn(), carton: vi.fn(), label: vi.fn(), plans: vi.fn(), create: vi.fn(), review: vi.fn(), cancel: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
const route = vi.hoisted(() => ({ name: 'overseas-return-inventory', query: {} as Record<string, string> }))
vi.mock('vue-router', () => ({ useRoute: () => route }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers, listSharedReceivingOverseasWarehouseOptions: api.warehouses }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, listReturnInventoryMovements: api.movements, listReturnPackingSources: api.sources, getReturnCarton: api.carton, getReturnCartonLabel: api.label }))
vi.mock('../api/relabelOrders', () => ({ listReturnPackingPlans: api.plans, createReturnPackingPlan: api.create, reviewReturnPackingPlan: api.review, cancelReturnPackingPlan: api.cancel }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
function carton(overrides: Partial<ReturnCartonDetail> = {}): ReturnCartonDetail { return { customerId: 'C1', overseasWarehouseId: 'W1', cartonMark: 'BOX-A', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 5, createdAt: '2026-10-20T09:00:00', sourceReturnNo: 'RETURN-1', fbaCartonMark: 'FBA-A', destinationWarehouseCode: 'ABE3', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 1, length: 30, width: 20, height: 10, weight: 2, items: [{ skuCode: 'SKU-A', productNameZh: '完整中文品名'.repeat(10), quantity: 5 }], ...overrides } }
function label(mark = 'BOX-A'): ReturnCartonLabel { return { ...carton({ cartonMark: mark }), printableHtml: '<html><body>服务端原标签</body></html>' } }
function plan(overrides: Partial<ReturnPackingPlanDto> = {}): ReturnPackingPlanDto { return { planNo: 'PLAN-001', customerId: 'C1', overseasWarehouseId: 'W1', returnNos: ['RETURN-1'], mode: 'SKU', status: 'PENDING_REVIEW', boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 2 }] }], idempotencyKey: 'saved-plan', sources: [{ cartonMark: 'BOX-A', sourceReturnNo: 'RETURN-1', participation: 'PARTIAL', originalItems: [{ skuCode: 'SKU-A', quantity: 5 }], items: [{ skuCode: 'SKU-A', quantity: 2 }] }], ...overrides } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true); route.name = 'overseas-return-inventory'; Object.keys(route.query).forEach(key => delete route.query[key])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户一', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue([{ id: 'W1', warehouseName: '海外仓一' }])
api.stock.mockResolvedValue({ records: [], total: 0 }); api.movements.mockResolvedValue({ records: [], total: 0 })
api.cartons.mockResolvedValue({ records: [carton()], total: 1 }); api.carton.mockImplementation(async () => carton())
api.label.mockImplementation(async mark => label(mark))
api.sources.mockResolvedValue({ records: [{ cartonMark: 'BOX-A', sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 4 }] }], total: 1 })
api.plans.mockResolvedValue({ records: [plan()], total: 1 }); api.create.mockResolvedValue(plan()); api.review.mockResolvedValue(plan()); api.cancel.mockResolvedValue(plan())
})
async function page(mode: 'inventory' | 'cartons' = 'inventory') { route.name = mode === 'inventory' ? 'overseas-return-inventory' : 'overseas-return-cartons'; const wrapper = mount(ReturnInventoryManagementView, { props: { mode }, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function row(wrapper: VueWrapper, businessNo: string) { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(businessNo)).at(-1)! }
function dialog(wrapper: VueWrapper, prefix: string) { return wrapper.findAllComponents(AppDialog).find(item => String(item.props('title')).startsWith(prefix) && item.props('modelValue'))! }
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, name: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === name)! }
async function openCreate() { const wrapper = await page(); await wrapper.findAll('button').find(item => item.text() === '创建合箱计划')!.trigger('click'); await flushPromises(); return { wrapper, form: dialog(wrapper, '创建退货合箱计划') } }
async function chooseScope(form: VueWrapper) { for (const [name, value] of [['客户', 'C1'], ['海外仓', 'W1']]) { const select = field(form, name!).getComponent({ name: 'ElSelect' }); if (select.props('modelValue') === value) continue; select.vm.$emit('update:modelValue', value); select.vm.$emit('change', value); await flushPromises() } }
async function selectBox(form: VueWrapper) { form.get('.packing-box-list-row').getComponent({ name: 'ElCheckboxGroup' }).vm.$emit('update:modelValue', ['BOX-A']); await flushPromises() }
async function openPlan() { const wrapper = await page(); const tabs = wrapper.findComponent({ name: 'ElTabs' }); tabs.vm.$emit('update:modelValue', 'plans'); tabs.vm.$emit('tab-change', 'plans'); await flushPromises(); return wrapper }
async function clickPlan(wrapper: VueWrapper, action = '详情') { await row(wrapper, 'PLAN-001').findAll('button').find(item => item.text() === action)!.trigger('click'); await flushPromises() }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! }
describe('return inventory operation dialogs', () => {
it('creates the original whole-box payload through the native fullscreen form', async () => {
const { form } = await openCreate(); await chooseScope(form); await selectBox(form)
await field(form, '合箱备注(可选)').get('textarea').setValue(' 合箱备注 ')
await form.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(form.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith({ returnNos: ['RETURN-1'], mode: 'BOX', boxes: [{ items: [] }], sourceCartonMarks: ['BOX-A'], sourceDeductions: undefined, remark: '合箱备注', idempotencyKey: expect.any(String) })
expect(api.sources).toHaveBeenLastCalledWith({ page: 1, size: 200, customerId: 'C1', overseasWarehouseId: 'W1' })
})
it('blocks invalid partial quantities and retains each physical carton and available maximum', async () => {
const { form } = await openCreate(); await chooseScope(form)
const mode = form.findComponent({ name: 'ElRadioGroup' }); mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const source = form.get('.packing-sku-row'); source.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true); await flushPromises()
const count = source.getComponent({ name: 'ElInputNumber' }); expect(count.props()).toMatchObject({ min: 1, max: 4, precision: 0 })
for (const value of [1.5, 5]) { count.vm.$emit('update:modelValue', value); form.vm.$emit('confirm'); await flushPromises() }
expect(api.create).not.toHaveBeenCalled()
count.vm.$emit('update:modelValue', 2); await flushPromises(); form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledWith(expect.objectContaining({ mode: 'SKU', boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 2 }] }], sourceCartonMarks: ['BOX-A'], sourceDeductions: [{ cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }] }))
})
it('locks creation and closing, keeping the same fields and idempotency key after failure', async () => {
let reject!: (error: Error) => void
api.create.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { form } = await openCreate(); await chooseScope(form); await selectBox(form)
await field(form, '合箱备注(可选)').get('textarea').setValue('失败后保留')
form.vm.$emit('confirm'); form.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce(); expect(form.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(field(form, '合箱备注(可选)').get('textarea').attributes('disabled')).toBeDefined()
const original = api.create.mock.calls[0]![0]
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'return-packing-request')); await flushPromises()
expect(form.text()).toContain('return-packing-request'); expect((field(form, '合箱备注(可选)').get('textarea').element as HTMLTextAreaElement).value).toBe('失败后保留')
form.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![0]).toEqual(original)
})
it('shows source diagnostics and retries the original customer and warehouse range', async () => {
api.sources.mockRejectedValueOnce(new ApiResponseError('来源失败', 'A0503', 503, null, 'return-source-request'))
const { form } = await openCreate(); await chooseScope(form)
expect(form.text()).toContain('return-source-request'); expect(form.props('confirmDisabled')).toBe(true)
form.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
await errorState(form, '合箱来源加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.sources).toHaveBeenLastCalledWith({ page: 1, size: 200, customerId: 'C1', overseasWarehouseId: 'W1' })
expect(form.find('.packing-box-list-row').exists()).toBe(true)
})
it('stops source pagination when a loading form is closed and ignores the late records', async () => {
let resolve!: (value: { records: Array<{ cartonMark: string; sourceReturnNo: string; items: Array<{ skuCode: string; quantity: number }> }>; total: number }) => void
api.sources.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, form } = await openCreate(); await chooseScope(form)
expect(form.props('confirmDisabled')).toBe(true)
await form.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
resolve({ records: [{ cartonMark: 'LATE-BOX', sourceReturnNo: 'RETURN-LATE', items: [{ skuCode: 'LATE-SKU', quantity: 1 }] }], total: 201 }); await flushPromises()
expect(api.sources).toHaveBeenCalledOnce()
await wrapper.findAll('button').find(item => item.text() === '创建合箱计划')!.trigger('click'); await flushPromises()
expect(dialog(wrapper, '创建退货合箱计划').text()).not.toContain('LATE-BOX')
})
it('keeps completed source, movement and result snapshots, with current-label actions first', async () => {
api.plans.mockResolvedValueOnce({ records: [plan({ status: 'COMPLETED', completedBy: '仓库操作员', completedAt: '2026-10-20T10:00:00', transfers: [{ returnNo: 'RETURN-1', sourceCartonMark: 'BOX-A', targetCartonMark: 'BOX-B', skuCode: 'SKU-A', quantity: 2, createdAt: '2026-10-20T10:00:00' }], completionCartons: [label('BOX-B'), label('OLD-BOX')], resultCartons: [carton({ cartonMark: 'BOX-B' }), carton({ cartonMark: 'OLD-BOX', status: 'MERGED', totalQuantity: 0 })] })], total: 1 })
const wrapper = await openPlan(); await clickPlan(wrapper); const view = dialog(wrapper, '合箱计划详情')
expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('取出来源'); expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('预计剩余')
expect(view.get('[aria-label="实际合箱流向"]').text()).toContain('BOX-B')
const results = view.get('[aria-label="合箱结果"]'); expect(results.text()).toContain('完成时快照'); expect(results.text()).toContain('30 × 20 × 10 cm / 2 kg')
expect(results.findAllComponents({ name: 'ElTableColumn' })[0]!.props('label')).toBe('操作')
const oldBox = results.findAll('.el-table__body tr').filter(item => item.text().includes('OLD-BOX')).at(-1)!
expect(oldBox.get('button').attributes('disabled')).toBeDefined(); await oldBox.get('button').trigger('click'); expect(api.label).not.toHaveBeenCalled()
await view.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(view.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true); expect(api.carton).not.toHaveBeenCalled()
})
it('keeps original carton facts, relabel counts and full SKU/merge contents in the native fullscreen detail', async () => {
api.carton.mockResolvedValueOnce(carton({ mergeHistory: [{ planNo: 'PLAN-001', sourceCartonMark: 'BOX-B', targetCartonMark: 'BOX-A', returnNo: 'RETURN-1', skuCode: 'SKU-A', quantity: 2, createdAt: '2026-10-20T10:00:00' }] }))
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
const view = dialog(wrapper, '退货箱号详情'); expect(view.props('title')).toBe('退货箱号详情 · BOX-A')
const facts = view.get('[aria-label="退货箱号资料"]')
expect(facts.findAll('th')).toHaveLength(11); expect(facts.findAll('input, select')).toHaveLength(0)
for (const value of ['客户一', '海外仓一', 'FBA-A', 'ABE3', 'RL-1', '实际完成 3 件', '处理中占用 1 件', '30×20×10 cm', '2 kg']) expect(facts.text()).toContain(value)
expect(view.findAllComponents(LineClampText).map(item => item.props('content'))).toContain('完整中文品名'.repeat(10))
expect(view.get('[aria-label="退货合箱明细"]').text()).toContain('合入')
await view.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises(); expect(view.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
})
it('locks carton reading and retries the exact business mark after failure', async () => {
let reject!: (error: Error) => void
api.carton.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
const view = dialog(wrapper, '退货箱号详情'); expect(view.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
reject(new ApiResponseError('箱号读取失败', 'A0503', 503, null, 'return-carton-request')); await flushPromises()
expect(view.text()).toContain('return-carton-request'); await errorState(view, '箱号详情加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.carton.mock.calls).toEqual([['BOX-A'], ['BOX-A']]); expect(view.text()).toContain('FBA-A')
})
it('keeps HTML printing on the original server label and isolates the popup opener', async () => {
const popup = { opener: {}, document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, focus: vi.fn(), print: vi.fn() }
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
const wrapper = await page('cartons'); await row(wrapper, 'BOX-A').findAll('button').find(item => item.text() === '箱唛')!.trigger('click'); await flushPromises()
expect(api.label).toHaveBeenCalledExactlyOnceWith('BOX-A'); expect(window.open).toHaveBeenCalledWith('', '_blank')
expect(popup.opener).toBeNull(); expect(popup.document.write).toHaveBeenCalledWith(label().printableHtml); expect(popup.document.open).toHaveBeenCalledOnce(); expect(popup.document.close).toHaveBeenCalledOnce(); expect(popup.focus).toHaveBeenCalledOnce(); expect(popup.print).toHaveBeenCalledOnce()
})
it('keeps review direct and cancellation native, with the plan number and mutual action lock', async () => {
const wrapper = await openPlan(); await clickPlan(wrapper, '审核'); expect(api.review).toHaveBeenCalledExactlyOnceWith('PLAN-001')
let resolve!: (value: ReturnPackingPlanDto) => void
api.cancel.mockReturnValueOnce(new Promise(done => { resolve = done }))
await clickPlan(wrapper, '取消'); expect(confirmation().text()).toContain('PLAN-001'); expect(confirmation().classes()).toContain('admin-density-confirm')
await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
expect(api.cancel).toHaveBeenCalledExactlyOnceWith('PLAN-001'); expect(row(wrapper, 'PLAN-001').findAll('button').filter(item => ['审核', '取消'].includes(item.text())).every(item => item.attributes('disabled') !== undefined)).toBe(true)
resolve(plan()); await flushPromises()
})
it('preserves plan context and diagnostics after review failure and never cancels after dismissal', async () => {
api.review.mockRejectedValueOnce(new ApiResponseError('审核失败', 'A0503', 503, null, 'packing-review-request'))
const wrapper = await openPlan(); await clickPlan(wrapper, '审核'); expect(wrapper.text()).toContain('packing-review-request')
await clickPlan(wrapper, '取消'); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises()
expect(api.cancel).not.toHaveBeenCalled(); expect(row(wrapper, 'PLAN-001').text()).toContain('PLAN-001')
})
it('keeps inventory-list users readonly and requires carton access for creation and labels', async () => {
can.mockImplementation(code => code === permission.returnInventoryList)
api.plans.mockResolvedValueOnce({ records: [plan({ status: 'COMPLETED', completionCartons: [label()], resultCartons: [carton()] })], total: 1 })
const readonly = await openPlan(); expect(readonly.findAll('button').some(item => item.text() === '创建合箱计划')).toBe(false)
expect(row(readonly, 'PLAN-001').findAll('button').map(item => item.text())).toEqual(['详情'])
await clickPlan(readonly); expect(dialog(readonly, '合箱计划详情').exists()).toBe(true)
expect(dialog(readonly, '合箱计划详情').findAll('button').some(item => item.text() === '打印当前箱唛')).toBe(false)
can.mockImplementation(code => code === permission.returnInventoryList || code === 'lastleg:inventory:manage')
const manageOnly = await openPlan(); expect(manageOnly.findAll('button').some(item => item.text() === '创建合箱计划')).toBe(false)
expect(row(manageOnly, 'PLAN-001').findAll('button').map(item => item.text())).toEqual(['详情', '审核', '取消'])
expect(api.label).not.toHaveBeenCalled(); expect(api.create).not.toHaveBeenCalled()
})
it('shows both candidate failures and preserves an entered remark across the original option reload', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'packing-customer-request'))
api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'packing-warehouse-request'))
const { form } = await openCreate(); expect(form.text()).toContain('packing-customer-request'); expect(form.text()).toContain('packing-warehouse-request')
await field(form, '合箱备注(可选)').get('textarea').setValue('候选失败后保留')
form.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
await errorState(form, '客户候选加载失败').get('.load-error-state__actions button').trigger('click'); await flushPromises()
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenCalledTimes(2)
expect((field(form, '合箱备注(可选)').get('textarea').element as HTMLTextAreaElement).value).toBe('候选失败后保留')
})
})
@@ -10,6 +10,7 @@ import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
import { listPlatformTenants } from '@/modules/iam'
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
vi.mock('vue-router', () => ({ useRoute: () => ({ name: 'overseas-return-inventory', query: routeQuery }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 }),
@@ -172,8 +173,10 @@ describe('ReturnInventoryManagementView shared list', () => {
it('shows source carton, return ownership and moved quantities in carton merge history', async () => {
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }], mergeHistory: [{planNo:'PLAN-MERGED',sourceCartonMark:'SOURCE-BOX',targetCartonMark:carton.cartonMark,returnNo:'RETURN-SOURCE',skuCode:'SKU-TEST',quantity:2,createdAt:'2026-10-08T14:00:00'}] })
const wrapper = await mountView('cartons')
await wrapper.findAll('button').find(button => button.text() === '详情')!.trigger('click')
const businessRow = wrapper.findAll('.el-table__body tr').filter(row => row.text().includes(carton.cartonMark)).at(-1)!
await businessRow.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(getReturnCarton).toHaveBeenCalledWith(carton.cartonMark)
expect(document.body.textContent).toContain('合箱明细')
expect(document.body.textContent).toContain('SOURCE-BOX')
expect(document.body.textContent).toContain('RETURN-SOURCE')
@@ -8,7 +8,7 @@ function source(fileName: string) {
function columnKeys(fileName: string) {
const value = source(fileName)
const start = value.indexOf('const columns: DataTableColumn[]')
const start = value.search(/const columns(?:: DataTableColumn\[\]| = computed<DataTableColumn\[\]>)/)
const end = value.indexOf('\n]', start)
return [...value.slice(start, end).matchAll(/\{ key: '([^']+)'/g)].map((match) => match[1])
}
@@ -57,7 +57,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps the operation column first on every business list', () => {
for (const fileName of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'DispatchesView.vue', 'InventoryView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
const value = source(fileName)
const columns = value.slice(value.indexOf('const columns: DataTableColumn[]'))
const columns = value.slice(value.search(/const columns(?:: DataTableColumn\[\]| = computed<DataTableColumn\[\]>)/))
expect(columns.match(/\{\s*key: '([^']+)'/)?.[1]).toBe('actions')
}
})
@@ -98,7 +98,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-if="previewUrl"')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-model="previewOpen"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
@@ -128,7 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('<OverseasDetailShell')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDrawer')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain(':title="`新增费用 · ${referenceValue}`"')
else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
else expect(value).not.toContain('AppDialog')
}
@@ -160,7 +160,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('createLastMileOrderFee')
expect(tailLeg).toContain('<span>费用明细</span>')
expect(tailLeg).toContain('<span>操作记录</span>')
expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain(':title="`新增费用 · ${referenceValue}`"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable compact />')
const inbound = source('InboundOrderDetailView.vue')
@@ -184,7 +184,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source('PalletsView.vue')
expect(value).toContain('<AppDialog')
expect(value).toContain('760px')
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : '编辑托盘'")
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`")
expect(value).toContain('createPallet')
expect(value).toContain('listAllPlatformWarehouses({ warehouseType: \'FBA\', status: \'ACTIVE\' })')
expect(value).toContain('placeholder="请选择海外仓"')
@@ -220,7 +220,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('type="primary" @click="openSmartDispatch"')
expect(value).not.toContain('type="primary" :disabled="!selectedRows.length" @click="openSmartDispatch"')
expect(value).toContain('access.can(permission.inboundOrderAppointment)')
expect(value).toContain('access.can(permission.inboundOrderSmartDispatch)')
expect(value).toContain('access.can(permission.dispatchList) && access.can(permission.dispatchCreate)')
expect(value).toContain('v-if="canCreateDispatch"')
expect(value).not.toContain('inboundOrderSmartDispatch')
expect(value).toContain('<SmartDispatchDialog')
expect(value).toContain("key: 'assigned', prop: 'assigned', label: '分配'")
expect(value).toContain("key: 'mergeAllowed', prop: 'mergeAllowed', label: '合托'")
@@ -253,61 +255,21 @@ describe('overseas warehouse first-batch prototype contract', () => {
}
})
it('connects intelligent dispatch without a frontend customerId constraint', () => {
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
expect(dialog).toContain('listDispatchCandidates')
expect(dialog).toContain('preArrangeDispatch')
expect(dialog).toContain("planPalletIds: sources.filter(item => item.sourceType !== 'RETURN_RECEIPT')")
expect(dialog).toContain("returnReceiptIds: sources.filter(item => item.sourceType === 'RETURN_RECEIPT')")
expect(dialog).toContain('planPalletIds: assigned.value.map(palletId)')
expect(dialog).toContain("if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')")
expect(dialog).toContain('lockDispatchDraftPallet')
expect(dialog).toContain('unlockDispatchDraftPallet')
expect(dialog.match(/async function close\([\s\S]*?\n}/)?.[0]).not.toContain('releaseDispatchDraft')
expect(dialog).toContain('await releaseDispatchDraft(draftToken.value)')
expect(dialog).toContain('释放并重新选择')
expect(dialog).toContain('排车明细已同步到预排列表')
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
expect(dialog).not.toContain('customerId:')
expect(dialog).toContain("planIds: query.sourceStage !== 'RELABEL' && planIds.value.length ? planIds.value.join(',') : undefined")
expect(dialog).toContain('v-model="query.fbaWarehouseCode" clearable')
expect(dialog).toContain("订单号 / 换标单号 / 原入仓码")
expect(dialog).toContain('label="私人仓"')
expect(dialog).toContain('item.deliveryAddress, item.postalCode')
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()')
expect(dialog).toContain('@click="removePallet(id)"')
expect(dialog).not.toContain('removePallet(candidates.find')
expect(dialog).toContain('洛杉矶')
expect(dialog).toContain('纽约')
expect(dialog).toContain("value: 'ALL', label: '全部'")
expect(dialog).toContain('换标下单')
expect(dialog).toContain('退货换标')
expect(dialog).toContain('本土散板')
expect(dialog).toContain('仓储转运')
expect(dialog).toContain('relabelCartonMarks(item)')
expect(dialog).toContain('type="warning">换标</el-tag>')
expect(dialog).not.toContain('function cartonMarks(')
expect(dialog).not.toContain('`normal-${mark}`')
expect(dialog).not.toContain('暂无箱唛')
expect(dialog).toContain('class="warehouse-tabs density-tabs"')
expect(dialog).toContain('dispatchVehicleSpecs')
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
// A filtered candidate must still submit its complete server-owned selection group.
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
expect(dialog).toContain('同仓可混装多个客户与目的仓')
expect(dialog).not.toContain('一次排车只能装同一客户')
expect(dialog).not.toContain('ensureDraftToken')
expect(dialog).not.toContain('preArrangeDispatch')
expect(dialog).toContain('原请求结果尚待核对')
expect(dialog).not.toContain('v-model="route"')
const vehicleSpecs = source('../dispatchVehicleSpecs.ts')
expect(vehicleSpecs).toContain("label: '大车(30托)'")
expect(vehicleSpecs).toContain("label: '小车(15托)'")
expect(vehicleSpecs).not.toContain('14托')
expect(vehicleSpecs).not.toContain('12托')
expect(dialog).not.toContain('排车路线(选填)')
expect(dialog).not.toContain('v-model="route"')
const dispatches = source('DispatchesView.vue')
expect(dispatches).toContain('v-model="editForm.route" readonly')
expect(dispatches).not.toContain('route: editForm.route.trim()')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
@@ -318,7 +280,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(transactions).toContain('业务订单号')
expect(transactions).toContain('入库单号')
expect(transactions).toContain('出库单号')
expect(transactions).toContain('不提供出库单跳转或库存变更操作')
expect(transactions).not.toContain('本页面仅用于查询库存流水')
expect(transactions).not.toContain('当前流水 DTO')
expect(transactions).toContain("router.push({ name: 'overseas-inventory' })")
expect(transactions).not.toContain('保存')
@@ -349,9 +311,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source(fileName)
expect(value).toContain(routeName)
if (fileName === 'DispatchesView.vue') {
expect(value).toMatch(/<RowActions\b[^>]*:actions="rowActions\(row as DispatchListItem\)"/)
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
expect(value).toContain("if (command === 'detail') openDetail(row)")
expect(value).toMatch(/<RowActions\b[^>]*:actions="rowActions\(row as DispatchListRow\)"/)
expect(value).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(value).toContain("if (value === 'detail') return openDetail(row)")
} else if (fileName === 'LastMileOrdersView.vue') {
expect(value).toContain('<RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }")
@@ -79,6 +79,6 @@ describe('overseas warehouse mounted real API pages', () => {
expect(wrapper.text()).toContain('详情缺少客户范围')
expect(wrapper.text()).not.toContain('9007199254740995')
})
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('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.find('.read-only-boundary').exists()).toBe(false); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
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.text()).toContain('货柜节点'); expect(wrapper.text()).toContain('已清关、可提柜'); expect(wrapper.text()).not.toContain('清关状态'); 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) })
})
@@ -9,10 +9,10 @@ export const overseasWarehousePermissions = {
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
discardList: 'platform:overseas-warehouse:discard:list',
discardApprove: 'platform:overseas-warehouse:discard:approve',
dispatchList: 'platform:overseas-warehouse:dispatch:list',
dispatchCreate: 'platform:overseas-warehouse:dispatch:create',
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
dispatchTransferDelivery: 'platform:overseas-warehouse:dispatch:transfer-delivery',
@@ -3,8 +3,8 @@ import { matchesSmartDispatchWarehouseTab } from './smartDispatchWarehouseTab'
describe('smart dispatch warehouse tabs', () => {
it('uses the overseas warehouse code when the warehouse name is unavailable', () => {
const losAngeles = { overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: null }
const newYork = { overseasWarehouseCode: 'US-NY-01', overseasWarehouseName: null }
const losAngeles = { warehouseCode: 'US-LAX-01', warehouseName: '' }
const newYork = { warehouseCode: 'US-NY-01', warehouseName: '' }
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'LAX')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'NYC')).toBe(false)
@@ -13,7 +13,7 @@ describe('smart dispatch warehouse tabs', () => {
})
it('keeps the all tab unfiltered and supports warehouse names as a fallback', () => {
const warehouse = { overseasWarehouseCode: null, overseasWarehouseName: '洛杉矶接柜仓' }
const warehouse = { warehouseCode: '', warehouseName: '洛杉矶接柜仓' }
expect(matchesSmartDispatchWarehouseTab(warehouse, 'ALL')).toBe(true)
expect(matchesSmartDispatchWarehouseTab(warehouse, 'LAX')).toBe(true)
@@ -1,13 +1,13 @@
import type { DispatchPalletCandidateDto } from './api/dispatches'
import type { Candidate } from './dispatchContracts'
export type SmartDispatchWarehouseTab = 'ALL' | 'LAX' | 'NYC'
export function matchesSmartDispatchWarehouseTab(
candidate: Pick<DispatchPalletCandidateDto, 'overseasWarehouseCode' | 'overseasWarehouseName'>,
candidate: Pick<Candidate, 'warehouseCode' | 'warehouseName'>,
tab: SmartDispatchWarehouseTab,
) {
if (tab === 'ALL') return true
const warehouse = [candidate.overseasWarehouseCode, candidate.overseasWarehouseName]
const warehouse = [candidate.warehouseCode, candidate.warehouseName]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
+4 -1
View File
@@ -7,10 +7,13 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehou
describe('overseas warehouse presentation contracts', () => {
it('covers every formal list without freezing an API transport', () => {
for (const name of [
'LastMileOrderListItem', 'InboundOrderListItem', 'DispatchListItem', 'InventoryListItem',
'LastMileOrderListItem', 'InboundOrderListItem', 'InventoryListItem',
'RelabelOrderListItem', 'PalletListItem', 'CartonListItem',
'ProductListItem', 'InventoryTransactionListItem',
]) expect(source).toContain(`interface ${name}`)
const native = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/dispatchContracts.ts'), 'utf8')
expect(native).toContain('interface DispatchListRow')
expect(source).not.toContain('interface DispatchListItem')
expect(source).not.toContain('axios')
expect(source).not.toContain("from '@/shared/api/http'")
expect(source).not.toMatch(/https?:\/\//)
-23
View File
@@ -100,29 +100,6 @@ export interface InboundOrderListItem {
version: number
}
export interface DispatchListItem {
actions?: import('./api/dispatches').DispatchActions
workflow?: import('./api/dispatches').DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate: string
driverName: string
driverPhone: string
carrierName: string
orderNos: string
route: string
overseasWarehouse: string
departAt: string
fbaAppointment: string
vehicleAppointment: string
estimatedArrivalAt: string
status: string
statusCode: string
palletCount: number
packageCount: number
createdAt: string
}
export interface InventoryListItem {
id: string
customerId: string