优化
This commit is contained in:
@@ -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,131 @@
|
||||
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 only an existing appointment reference and its explicit version', async () => {
|
||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
|
||||
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, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
|
||||
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' }) })
|
||||
})
|
||||
|
||||
@@ -1,346 +1,65 @@
|
||||
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)
|
||||
if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
|
||||
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 })
|
||||
|
||||
@@ -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}` }) 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,54 @@
|
||||
<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">
|
||||
<template v-if="groups[index]">
|
||||
<h3>{{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}</h3>
|
||||
<p v-for="reason in previewGroup.blockReasons" :key="reason" role="alert">{{ reason }}</p>
|
||||
<div class="arrange-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<el-form-item label="承运商"><el-input v-model="groups[index]!.carrierName" :disabled="disabled" placeholder="请输入承运商(选填)" /></el-form-item><el-form-item label="运单号"><el-input v-model="groups[index]!.waybillNo" :disabled="disabled" placeholder="请输入运单号(选填)" /></el-form-item>
|
||||
<el-form-item label="车型"><el-input v-model="groups[index]!.vehicleType" :disabled="disabled" placeholder="请输入本组派送车型(选填)" /></el-form-item><el-form-item label="车辆尺寸"><el-input v-model="groups[index]!.vehicleSize" :disabled="disabled" placeholder="请输入车辆尺寸(选填)" /></el-form-item>
|
||||
<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><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><el-form-item label="备注"><el-input v-model="groups[index]!.remark" :disabled="disabled" placeholder="请输入本组派送备注(选填)" /></el-form-item>
|
||||
</div>
|
||||
<el-table :data="previewGroup.cargo" border max-height="240"><el-table-column prop="orderNo" label="来源订单" min-width="150" /><el-table-column prop="inboundCode" label="入仓码" min-width="150" /><el-table-column prop="palletNo" label="实物托盘码" min-width="150" /><el-table-column prop="cartonMark" label="来源箱唛" min-width="150" /><el-table-column prop="packages" label="本次箱数" width="100" /></el-table>
|
||||
<p v-for="quote in previewGroup.externalQuotes" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.inboundCode }}:{{ quote.currency }} {{ quote.amount }}(外接约定运费)</p>
|
||||
<h4>原费用覆盖</h4><p v-if="!previewGroup.feeSources.length">暂无可引用原费用。</p>
|
||||
<div v-for="fee in previewGroup.feeSources" :key="fee.financeFeeId"><span>{{ fee.currency }} {{ fee.amount }} · {{ fee.status }}</span><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></div>
|
||||
<h4>本组新增费用</h4><div class="arrange-grid"><template v-for="fee in groups[index]!.fees.filter(item => required(index, previewGroup, item.feeType))" :key="fee.feeType"><el-form-item v-if="!covered(index, previewGroup, fee.feeType)" :label="`${serviceLabels[fee.feeType] || fee.feeName}(未覆盖部分)`" required><el-input-number v-model="fee.amount" :disabled="disabled" :min="0.01" :precision="2" placeholder="请输入实际新增应收" /><el-select v-model="fee.currency" :disabled="disabled" placeholder="币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item><p v-else>本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。</p></template></div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.arrange-group { padding-bottom: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
||||
.arrange-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.arrange-grid :deep(.el-select), .arrange-grid :deep(.el-date-editor), .arrange-grid :deep(.el-input-number) { width: 100%; }
|
||||
</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,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.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" :aria-label="label"><h4>{{ label }}</h4>
|
||||
<p v-if="error" role="alert">{{ error.message }} <el-button link @click="load">重新读取资料</el-button></p><ApiErrorMeta v-if="error" :details="error" />
|
||||
<ul><li v-for="id in ids" :key="id"><span>{{ metadata[id]?.fileName || '附件资料待核实' }}</span> <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></li></ul>
|
||||
<p v-if="!ids.length">暂无本趟附件。</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>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="900px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><p v-if="previewLoading">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dispatch-evidence { padding: var(--admin-space-4) 0; }
|
||||
.dispatch-evidence ul { margin: 0; padding-left: 20px; }
|
||||
.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('renders known business labels for existing master resources without falling back to their internal IDs', async () => {
|
||||
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 }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
|
||||
})
|
||||
it('resets only the resource being switched and keeps external resource choices independent', async () => {
|
||||
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 }, 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('requires each real directory permission before reading candidates', async () => {
|
||||
can.mockReturnValue(false); const value = resources()
|
||||
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, 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,53 @@
|
||||
<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 }>()
|
||||
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>
|
||||
<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>
|
||||
<style scoped>
|
||||
.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,141 @@
|
||||
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 } 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.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 { selected: string[] }).selected = [value.sourceKey]
|
||||
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 { selected: string[] }).selected = [value.sourceKey]; 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 } 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,216 @@
|
||||
<script setup lang="ts">
|
||||
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 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 } 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<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
|
||||
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
|
||||
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 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 = candidates.value.filter(row => selected.value.includes(row.sourceKey))
|
||||
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: chosen.map(row => row.selection), 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 } : {}) })
|
||||
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]" :width="width" :confirm-loading="busy" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
<p>排车单号 {{ currentNo || '—' }}</p><p v-if="pending" role="status">原操作结果尚待核对,当前使用原版本和原请求内容。</p>
|
||||
<LoadErrorState v-if="error" title="排车操作未完成" :message="error.message" :loading="loading" @retry="pending ? submit() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending?.rejected" :disabled="busy" @click="recovery.discardRejected(); load()">按明确驳回结果重新核对</el-button>
|
||||
<p v-if="detail?.actions[currentAction]?.allowed !== true && !pending">{{ detail?.actions[currentAction]?.blockReason || '当前操作资格未返回,请刷新核对' }}</p>
|
||||
<el-form v-if="detail" class="density-form" label-position="top">
|
||||
<template v-if="currentOperation === 'ROUTE' && route"><p>起运仓:{{ route.stops[0]?.name || '—' }} · {{ route.stops[0]?.address || '—' }}</p><p>{{ route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。' }}</p><ol><li v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index">{{ stop.name }} · {{ stop.address || '—' }} <el-button :disabled="frozen || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></li></ol></template><template v-else-if="currentAction === 'REMOVE_CARGO' && items"><el-table :data="items.items" border max-height="420"><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="来源订单" /><el-table-column prop="inboundCode" label="入仓码" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="scheduledPackages" label="本次箱数" /><el-table-column prop="pickedPackages" label="真实已拣" /><el-table-column label="移货资格"><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table></template><template v-else-if="currentAction === 'EDIT'"><DispatchResourceFields v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen" /><div class="form-grid"><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></div></template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'"><p>{{ detail.vehicleInfo.vehicle?.plate || '—' }} · {{ detail.vehicleInfo.driver?.name || '—' }} · {{ detail.counts.palletCount }}托/{{ detail.counts.packageCount }}箱</p><p v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status }}</p><p>请核对本车资源与本趟证据;审核通过不代替后续现场扫码。</p><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></template>
|
||||
<template v-else-if="currentAction === 'ADD_CARGO'"><div class="density-actions"><el-input v-model="candidateKeyword" :disabled="frozen" placeholder="订单号/入仓码" /><el-button :disabled="frozen" @click="searchCargo">查询</el-button></div><el-table :data="candidates" border max-height="380"><el-table-column label="选择" width="70"><template #default="{ row }"><el-checkbox :model-value="selected.includes(row.sourceKey)" @change="selected = $event === true ? [...selected, row.sourceKey] : selected.filter(key => key !== row.sourceKey)" :disabled="frozen || row.blockReasons.length > 0" :aria-label="`选择货物 ${row.palletNo || row.orderNo || '待核实'}`" /></template></el-table-column><el-table-column prop="customerName" label="客户" /><el-table-column prop="orderNo" label="订单号" /><el-table-column prop="palletNo" label="托盘码" /><el-table-column prop="availablePackages" label="可排箱数" /><el-table-column label="目的仓"><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table></template>
|
||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview"><p>{{ preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。' }}</p><el-table :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" /><el-table-column label="实际托位"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen" :min="1" placeholder="请确认" /></template></el-table-column><el-table-column label="实测重量(kg)"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="实测体积(m³)"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen" :min="0.001" placeholder="请核对真实实测" /></template></el-table-column><el-table-column label="已匹配卸货顺序"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table><p v-for="pallet in preview.pallets" :key="pallet.warehousePalletId">{{ pallet.palletNo }}:<span v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '实测依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }} {{ evidence.value ?? '—' }};</span></p></template>
|
||||
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen" />
|
||||
<p v-else-if="currentAction === 'DEPART'">确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。</p><p v-else-if="currentAction === 'RETURN_TO_REVIEW'">仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。</p><p v-else-if="currentAction === 'CANCEL'">确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。</p>
|
||||
<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" @busy="evidenceBusy.VEHICLE = $event" /><DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen" @busy="evidenceBusy.DRIVER = $event" /></template><el-form-item v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入操作原因或审核意见" /></el-form-item>
|
||||
<el-form-item v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" label="备注"><el-input v-model="form.remark" :disabled="frozen" type="textarea" :rows="3" placeholder="请输入本次备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.form-grid :deep(.el-date-editor) { width: 100%; }
|
||||
</style>
|
||||
@@ -1,338 +1,107 @@
|
||||
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]])
|
||||
wrapper.unmount()
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import * as api from '../api/dispatches'
|
||||
import { candidate, command, detail } 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' })
|
||||
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.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.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('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()
|
||||
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 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('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: '客户乙' })); 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, source().selection] }) })] }))
|
||||
expect(wrapper.emitted('created')).toHaveLength(1); 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('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('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('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('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('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('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('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.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('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('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 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('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
@@ -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,100 @@
|
||||
// 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 VehicleAppointment { referenceNo: string; status: string; appointmentAt: string | null }
|
||||
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 }
|
||||
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[]; vehicleAppointment: VehicleAppointment | null; 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; vehicleAppointment?: { referenceNo: string; recordVersion: Version } | 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 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[] }
|
||||
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 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[] }
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
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 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: [], vehicleAppointment: null, 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' }], ...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 { return { selection: { sourceType: 'INBOUND_PLAN', 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 } }
|
||||
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,89 @@
|
||||
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() })
|
||||
|
||||
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: [] }], 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,71 @@
|
||||
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 } 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 }
|
||||
export type CandidateWire = LongFields<Candidate, 'availablePackages'>
|
||||
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
|
||||
type PreviewGroupWire = Omit<PreviewGroup, 'cargo'> & { cargo: Array<LongFields<CargoLine, 'packages' | 'productQuantity'>> }
|
||||
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); return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages') } }
|
||||
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) } }
|
||||
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') })) })), 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,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, 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,7 +58,8 @@ 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')
|
||||
|
||||
@@ -3,77 +3,54 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDispatch } 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 }) }))
|
||||
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() }))
|
||||
|
||||
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()
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ 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 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: '—' }])
|
||||
wrapper.unmount()
|
||||
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')
|
||||
expect(wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`).text()).toContain('LAX → ONT8')
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); 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 })])
|
||||
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 })
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
|
||||
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); 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('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.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' })
|
||||
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()).toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); 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,95 @@
|
||||
<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 } 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))]
|
||||
})
|
||||
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 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 actionBlockReasons = computed(() => [...new Set(Object.values(detail.value?.actions || {}).map(gate => gate?.allowed === true ? '' : gate?.blockReason).filter(Boolean))])
|
||||
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)], ['车辆状态', text(detail.value?.vehicleInfo.vehicle?.vehicleStatus)],
|
||||
])
|
||||
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(';') || '—'],
|
||||
['车辆预约', detail.value?.dispatchInfo.vehicleAppointment ? [detail.value.dispatchInfo.vehicleAppointment.referenceNo, detail.value.dispatchInfo.vehicleAppointment.status, time(detail.value.dispatchInfo.vehicleAppointment.appointmentAt)].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: '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="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><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,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[]>([])
|
||||
@@ -145,6 +146,7 @@ function openAppointment() {
|
||||
}
|
||||
|
||||
function openSmartDispatch() {
|
||||
if (!canCreateDispatch.value) return
|
||||
smartDispatchOpen.value = true
|
||||
}
|
||||
|
||||
@@ -234,7 +236,7 @@ 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>
|
||||
|
||||
@@ -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 })
|
||||
@@ -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,7 +545,7 @@ 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('预计到仓时间')
|
||||
@@ -572,7 +572,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 +598,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 +610,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,7 +634,7 @@ 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' })
|
||||
@@ -944,7 +944,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 +974,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,7 +1183,7 @@ 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()
|
||||
|
||||
@@ -1000,9 +1000,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) },
|
||||
]
|
||||
@@ -1124,7 +1124,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 +1135,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>
|
||||
@@ -1277,7 +1277,7 @@ onMounted(() => { void load() })
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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,19 @@ 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)
|
||||
expect(dialog).toContain('cargo: cargo.map(row => row.selection)')
|
||||
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', () => {
|
||||
@@ -349,9 +309,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 }")
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
@@ -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?:\/\//)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user