merge admin frontend changes
This commit is contained in:
@@ -43,6 +43,8 @@
|
||||
|
||||
- 一件代发出库候选选择:`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。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
|
||||
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取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`。应用完整门禁继续遵循上层指引。
|
||||
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
|
||||
| FBA 预约详情/编辑 | `GET /fba-appointments/{appointmentNo}?customerId=...`;`PUT` 同 path | PUT body:`customerId,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | GET 同 list;PUT 同 appointment;失败保留弹框和错误元数据 |
|
||||
| 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 |
|
||||
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` |
|
||||
| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 |
|
||||
| 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?`;退回候选返回`sourceType=RETURN_RECEIPT,returnReceiptId,returnReceiptNo`,箱数为实际剩余可拣量 | 同 inbound list;已实收阶段包含真实退回来源,展示业务凭据号;草稿恢复失败保留选择并允许重试 |
|
||||
| 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId`或`sourceType=RETURN_RECEIPT,returnReceiptId`,及`draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],returnReceiptIds?:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;任何已选来源未恢复时禁止提交,不能过滤后静默少发;不调用编辑、取消、depart、PDA 或派送 |
|
||||
| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户;列表/详情客户只显示 `customerName`,海外仓只显示 `overseasWarehouseName`,空值为 `—` | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship;不以客户 ID、本地客户候选、国内来源仓或目的仓猜名称 |
|
||||
| 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 |
|
||||
| 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 |
|
||||
@@ -32,3 +32,5 @@
|
||||
- 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。
|
||||
- 箱号和产品库不设独立正式 routes;海外出库、退货出库、海外派送、PDA、卸柜照片和库存离仓命令均按海外仓全流程的正式页面与接口归属验收,内部事件仍只作为服务端协作事实。
|
||||
- 1920×1080、浏览器 100% 的人工截图仍待验收。
|
||||
|
||||
| 异常详情及处理 | `GET /exception-orders/{exceptionNo}`;`POST /{exceptionNo}/start-processing`、`/complete`、`/cancel`、`/evidence`(均在 `/platform/v1/last-leg/exception-orders` 下) | 显式customerId;命令携带expectedVersion和原idempotencyKey;complete携证据及服务端冻结来源对应本批实收、真实目标托盘/库位;分批后重新读取剩余量;version/数量规范化为安全整数,业务ID保留字符串;历史证据不自动提交,本批选用1–20份;目标采用一次托盘详情+一次`GET /platform/v1/overseas-warehouse-resources/{warehouseId}/locations/{id}`核对,不遍历全仓;普通说明选填并提交标准审计说明 | 详情沿list;处理及上传均 `platform:overseas-warehouse:exception-order:process`;既有居中详情内操作,未知结果锁定原载荷仅重试原键,明确版本拒绝刷新;无目标资源读取权限时显示错误,不猜库位 |
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# 海外仓原型核对矩阵
|
||||
|
||||
### F33 已有库存转运履约接入(2026-10-04)
|
||||
|
||||
| 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 尾程订单详情 | 本票转运、换标状态和已确认分配只读 | 原型 `03-overseas-warehouse.html:927` 的独立详情;阶段五9.1–9.4;本轮明确复用现有详情 | 在现有计划信息页签按需读取本票 `sourceOrderNo`,不增加页面;缺来源单号失败关闭,不以尾程号/内部ID替代 | 定向行为检查;1920×1080待人工截图确认 |
|
||||
| 尾程订单详情 | 查看关联换标/PDA进度 | 本轮要求沿本票关联换标;现有 `overseas-relabel-order-detail` | 使用返回的 `relabelNo` 进入既有独立详情;客户确认前明确禁止提前出库,不提供跳过按钮 | 定向权限/状态检查;待匹配后端发布及真实页面 |
|
||||
| 尾程订单详情 | 库存来源选择/分配 | 本轮明确复用详情/居中表单;当前 Allocate DTO;总控冻结逐绑定来源→分页→代表余额详情→位置的读取链 | 1320px居中表单,只确认客户/操作仓/绑定入仓/具体旧SKU/位置与托盘;歧义待核查不误报零库存。OCCUPIED不作不可用门禁,available=0及MIXED禁选;数量合计本票箱数。未知结果原键原内容重试;明确业务拒绝提供“重新选择来源”,保留上次诊断并重查原票/候选后确认新命令,不改写原命令 | 定向接口/数量/权限/失败重放与拒绝恢复检查;待匹配后端及真实页面取证 |
|
||||
|
||||
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
|
||||
|
||||
当前范围按海外仓全流程维护。箱号管理、产品库不设独立正式路由,其数据通过入仓、箱唛、SKU 与库存页面承载;正式范围包含尾程订单、入库单、换标、转运、退货、托盘、库存、排车、出库、派送、异常退回、客户回访和一件代发。
|
||||
|
||||
废弃审核补充:`page-last-mile-orders` 原型未定义废弃审核弹框。依当前对话“你确认就好了”的常规交互决策授权,沿用本页 `reviewReturn` / `reviewDirectRelabel` 的居中确认框,不新增路由或抽屉。只对有 `lastleg:cargo-discard:approve` 权限的待审核废弃行提供审核;框内显示废弃申请号,通过或驳回,驳回原因必填且最多 1000 字,关闭不提交,处理期间禁止重复点击。仅提交审核结论,不增加扣库命令或声明扣库时点。详情仍走本页原独立详情路由。本段定义承载方式,部署及验收状态只记录在测试环境主契约。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
|
||||
@@ -57,3 +67,33 @@
|
||||
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
|
||||
|
||||
`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
|
||||
|
||||
|
||||
### 车辆外接运费增量(2026-10-03)
|
||||
|
||||
| 页面 | 操作 | 依据 | 承载方式 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|---|
|
||||
| 排车列表 | 转派送费用核对 | 用户新版原型12374行1120px居中弹框、12404行ove-panel-delivery费用分区;当前对话确认原费用覆盖、新增项目收费 | 保留现有1120px三列居中转派送弹框 | 费用分区带出外接实收报价,全部外接不重复填写派送应收;无新增抽屉或页面 | 本地交互测试通过,1920×1080实际页面待验收 |
|
||||
|
||||
|
||||
### 取消费用确认增量(2026-10-03)
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 出库列表 | 取消确认 | 沿现有居中原因确认框;本轮首版费用规则 | 提示先解除车次、费用申请与财务结果分别处理,不再提示库存已自动回滚 | 交互检查通过,真实页面待验收 |
|
||||
| 派送详情 / 货柜费用面板 | 费用只读及作废 | 沿现有详情费用表与原因弹框;财务确认状态为本轮服务端契约补充 | 展示待财务确认/待财务处理/已作废,待决时关闭重复作废和调整 | 前端检查通过,1920×1080待验收 |
|
||||
|
||||
|
||||
### 原运费覆盖增量(2026-10-03)
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表 | 转派送费用确认 | 用户已确认逐票原费用覆盖;复用新版原型12374行1120px三列居中弹框 | DeliveryFeeCoveragePanel逐票显示原费和服务勾选,新增费用仅针对未覆盖部分;不新增抽屉或路由 | 交互测试通过,1920×1080真实页面待验收 |
|
||||
| 派送详情 | 费用只读 | 沿现有费用页签 | 展示确认时原费版本与服务,原金额不计作本次新增 | 本地展示测试通过,真实页面待验收 |
|
||||
|
||||
### 退回货物重新预排(2026-10-03)
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 |
|
||||
| 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 |
|
||||
|
||||
@@ -23,6 +23,15 @@ describe('delivery order API', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
|
||||
})
|
||||
|
||||
it.each([null, 0, 3])('preserves actual nullable signed quantity %s in list and detail', async signedPackages => {
|
||||
const order = { deliveryNo: 'DEL-1', version: '0', packageCount: 9, signedPackages }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [order], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
const page = await listDeliveryOrders({ page: 1, size: 20 })
|
||||
expect(page.records[0]?.signedPackages).toBe(signedPackages)
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { order, skus: [], fees: [], operations: [] } })
|
||||
expect((await getDeliveryOrder('DEL-1', '1001')).order.signedPackages).toBe(signedPackages)
|
||||
})
|
||||
|
||||
it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } })
|
||||
const detail = await getDeliveryOrder('DEL/1', '1001')
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface DeliveryOrderRow {
|
||||
sellerName?: string | null
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
signedPackages: number | null
|
||||
totalWeightKg?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
plannedDeliveryAt?: string | null
|
||||
@@ -65,9 +66,9 @@ export interface DeliveryOrderRow {
|
||||
}
|
||||
|
||||
export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; productQuantity?: number | null; weightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
|
||||
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null; reviewStatus?: string | null; sourceStatus?: string | null; voidStatus?: string | null }
|
||||
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
|
||||
|
||||
export interface DeliveryReturnSource {
|
||||
outboundPalletItemId: string
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } 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('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()
|
||||
|
||||
@@ -9,9 +9,16 @@ export interface DispatchQuery {
|
||||
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 DispatchPalletCandidateDto {
|
||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||
returnReceiptId?: string | null
|
||||
returnReceiptNo?: string | null
|
||||
planId?: string | null
|
||||
planPalletId?: string | null
|
||||
warehousePalletId?: string | null
|
||||
@@ -60,6 +67,7 @@ export interface DispatchDraftDto { draftToken: string; containers?: DispatchDra
|
||||
|
||||
export interface PreArrangeDispatchPayload {
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds?: string[]
|
||||
fbaAppointmentNo?: string
|
||||
fbaAppointmentStatus?: string
|
||||
vehicleAppointmentStatus?: string
|
||||
@@ -67,7 +75,22 @@ export interface PreArrangeDispatchPayload {
|
||||
relabelNo?: string
|
||||
}
|
||||
|
||||
export interface PreArrangeDispatchDraftGroup {
|
||||
draftContainerId: string
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds: string[]
|
||||
}
|
||||
export interface PreArrangeDispatchDraftPayload {
|
||||
draftToken: string
|
||||
groups: PreArrangeDispatchDraftGroup[]
|
||||
}
|
||||
export interface PreArrangeDispatchDraftResult {
|
||||
draftContainerId: string
|
||||
dispatch: DispatchSummaryDto
|
||||
}
|
||||
|
||||
export interface DispatchSummaryDto {
|
||||
workflow?: DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
vehiclePlate?: string | null
|
||||
@@ -198,9 +221,21 @@ export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function lockDispatchDraftPallet(planPalletId: string, draftToken: string, draftContainerId?: string) {
|
||||
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', { planPalletId, draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -218,9 +253,9 @@ export async function deleteDispatchDraftContainer(draftToken: string, container
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function unlockDispatchDraftPallet(planPalletId: string, draftToken: string) {
|
||||
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', { planPalletId, draftToken },
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -235,6 +270,9 @@ export async function releaseDispatchDraft(draftToken: string) {
|
||||
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
|
||||
@@ -262,3 +300,24 @@ export async function transferDispatchToDelivery(dispatchNo: string, payload: Tr
|
||||
)
|
||||
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) })) }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from './exceptionOrders'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('exception order API', () => {
|
||||
it('preserves receipt and dispatch business numbers while normalizing all actual history quantities', () => {
|
||||
const raw = { order: { version: '2' }, returnSources: [], returnReceipts: [{ receiptNo: 'RSR-9007199254740993', receivedPackages: '2', receivedAt: '2026-10-03T23:24:13',
|
||||
redispatches: [{ dispatchNo: 'DS/1', status: 'DEPARTED', selectedPackages: '2', pickedPackages: '2', deliveries: [{ deliveryNo: 'DL/1', status: 'DELIVERED' }] }] }] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.returnReceipts[0]).toMatchObject({ receiptNo: 'RSR-9007199254740993', receivedPackages: 2,
|
||||
redispatches: [{ dispatchNo: 'DS/1', selectedPackages: 2, pickedPackages: 2 }] })
|
||||
raw.returnReceipts[0]!.receivedPackages = null as unknown as number
|
||||
expect(() => normalizeExceptionDetail(raw)).toThrow('历史实收数量无效')
|
||||
})
|
||||
it('normalizes actual string versions and quantities without converting business references to numbers', () => {
|
||||
const raw = { order: { version: '7', evidenceFileIds: ['190000000000000099'] }, returnSources: [{ outboundPalletItemId: '190000000000000098', inventoryPositionId: '190000000000000097', maxReturnPackages: '2' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.order.version).toBe(7); expect(result.returnSources[0]?.maxReturnPackages).toBe(2)
|
||||
expect(result.returnSources[0]?.outboundPalletItemId).toBe('190000000000000098')
|
||||
for (const version of ['', '-1', '1.5', '9007199254740993', undefined]) expect(() => normalizeExceptionDetail({ ...raw, order: { ...raw.order, version } } as unknown as ExceptionDetail)).toThrow()
|
||||
})
|
||||
it('loads authoritative detail and scopes every versioned command and evidence upload to its customer and business number', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version: '7', allowedActions: ['COMPLETE_EXCEPTION'] }, returnSources: [], returnReceipts: [] } })
|
||||
await getExceptionDetail('100', 'EX/1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
|
||||
for (const action of ['start-processing', 'cancel', 'complete'] as const) {
|
||||
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
|
||||
await commandException('100', 'EX/1', action, body)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
|
||||
}
|
||||
const file = new File(['evidence'], 'receipt.txt')
|
||||
await uploadExceptionEvidence('100', 'EX/1', file)
|
||||
const call = vi.mocked(http.post).mock.calls.at(-1)!
|
||||
expect(call[0]).toBe('/platform/v1/last-leg/exception-orders/EX%2F1/evidence')
|
||||
expect((call[1] as FormData).get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '100' } })
|
||||
})
|
||||
beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() })
|
||||
|
||||
it('queries the real paginated exception endpoint', async () => {
|
||||
|
||||
@@ -10,6 +10,43 @@ export interface ExceptionOrderRow {
|
||||
deliveryOrderId?: string | null; deliveryNo?: string | null; outboundNo?: string | null
|
||||
skuCode?: string | null; packageCount?: number | null; returnDirection?: string | null; returnLogisticsNo?: string | null
|
||||
handlingAction?: string | null; handlerName?: string | null; exceptionAt: string; updatedAt: string
|
||||
version?: number; allowedActions?: string[]; evidenceFileIds?: string[]; handlingResult?: string | null; completedAt?: string | null
|
||||
}
|
||||
|
||||
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
|
||||
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
|
||||
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
|
||||
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] }
|
||||
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
|
||||
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
|
||||
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
|
||||
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
|
||||
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
|
||||
function safeInteger(value: unknown, label: string) {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${label}无效,请重新加载`)
|
||||
return Number(value)
|
||||
}
|
||||
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
|
||||
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
|
||||
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
|
||||
returnReceipts: value.returnReceipts.map(receipt => ({ ...receipt, receivedPackages: safeInteger(receipt.receivedPackages, '历史实收数量'),
|
||||
redispatches: receipt.redispatches.map(dispatch => ({ ...dispatch, selectedPackages: safeInteger(dispatch.selectedPackages, '重派选定数量'),
|
||||
pickedPackages: safeInteger(dispatch.pickedPackages, '重派实拣数量') })) })) }
|
||||
}
|
||||
export async function getExceptionDetail(customerId: string, exceptionNo: string) {
|
||||
const response = await http.get<ApiEnvelope<ExceptionDetail>, ApiEnvelope<ExceptionDetail>>(exceptionPath(exceptionNo), { params: { customerId } })
|
||||
return normalizeExceptionDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function commandException(customerId: string, exceptionNo: string, action: ExceptionAction, payload: ExceptionCommand) {
|
||||
const response = await http.post<ApiEnvelope<ExceptionCommandResult>, ApiEnvelope<ExceptionCommandResult>>(`${exceptionPath(exceptionNo)}/${action}`, payload, { params: { customerId } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, version: safeInteger(result.version, '操作版本'), restoredPackages: safeInteger(result.restoredPackages, '实际入账数量') }
|
||||
}
|
||||
export async function uploadExceptionEvidence(customerId: string, exceptionNo: string, file: File) {
|
||||
const form = new FormData(); form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<{ fileId: string; fileName: string }>, ApiEnvelope<{ fileId: string; fileName: string }>>(`${exceptionPath(exceptionNo)}/evidence`, form, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export interface ExceptionRelation {
|
||||
lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { reviewCargoDiscard } from './lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('last-mile order overseas API', () => {
|
||||
it('reviews a discard by business number with an explicit string customer query', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { status: 'REJECTED' } })
|
||||
await expect(reviewCargoDiscard('CD/TEST-1', '2092618717861048321', 'REJECT', ' 箱数不符 ')).resolves.toEqual({ status: 'REJECTED' })
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/cargo-discards/CD%2FTEST-1/review',
|
||||
{ decision: 'REJECT', remark: '箱数不符' }, { params: { customerId: '2092618717861048321' } })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface LastMileOrderQuery {
|
||||
orderNo?: string
|
||||
relabelNo?: string
|
||||
orderType?: string
|
||||
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
|
||||
inboundCode?: string
|
||||
lxCode?: string
|
||||
containerNo?: string
|
||||
@@ -265,6 +266,7 @@ export interface ContainerInboundPlanPreviewGroupDto {
|
||||
}
|
||||
|
||||
export interface ContainerInboundPlanPreviewDto {
|
||||
resourceRetry?: { idempotencyKey: string; snapshotVersion: number; snapshotHash: string } | null
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
overseasWarehouseId: string | null
|
||||
@@ -365,6 +367,15 @@ export async function getLastMileOrder(orderId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewCargoDiscard(discardNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
|
||||
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`,
|
||||
{ decision, remark: remark?.trim() || undefined },
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
|
||||
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getOverseasLocation } from './locations'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: { get: vi.fn() } }))
|
||||
it('reads one location through its warehouse-scoped detail endpoint without scanning pages', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '40', warehouseId: '92', locationCode: 'LOC' } })
|
||||
expect(await getOverseasLocation('92', '40')).toEqual({ id: '40', warehouseId: '92', locationCode: 'LOC' })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/overseas-warehouse-resources/92/locations/40')
|
||||
})
|
||||
@@ -35,6 +35,11 @@ export interface OverseasLocationQuery { page?: number; size?: number; keyword?:
|
||||
|
||||
function base(warehouseId: string) { return `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations` }
|
||||
|
||||
export async function getOverseasLocation(warehouseId: string, locationId: string) {
|
||||
const response = await http.get<ApiEnvelope<OverseasLocationDto>, ApiEnvelope<OverseasLocationDto>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
describe('warehouse transfer quantity wire normalization', () => {
|
||||
it.each([{ value: '3', result: 3 }, { value: '0', result: 0 }, { value: 3, result: 3 }, { value: 0, result: 0 }, { value: '9007199254740991', result: Number.MAX_SAFE_INTEGER }])('normalizes $value to a safe integer', ({ value, result }) => {
|
||||
expect(normalizeWarehouseTransferQuantity(value, '箱数')).toBe(result)
|
||||
})
|
||||
it.each([{ value: '' }, { value: ' ' }, { value: null }, { value: undefined }, { value: true }, { value: false }, { value: '1.5' }, { value: 1.5 }, { value: '-1' }, { value: -1 }, { value: '1e2' }, { value: ' 3' }, { value: '3 ' }, { value: '0x3' }, { value: '9007199254740992' }, { value: Number.MAX_SAFE_INTEGER + 1 }, { value: Infinity }, { value: NaN }, { value: [] }, { value: {} }])('rejects invalid or ambiguous $value', ({ value }) => {
|
||||
expect(() => normalizeWarehouseTransferQuantity(value, '箱数')).toThrow('箱数返回格式无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
export function normalizeWarehouseTransferQuantity(value: unknown, label: string): number {
|
||||
const quantity = typeof value === 'number' ? value
|
||||
: typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : Number.NaN
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 0) {
|
||||
throw new ApiResponseError(`${label}返回格式无效,请重新加载或联系管理员核查。`, 'TRANSFER_QUANTITY_INVALID')
|
||||
}
|
||||
return quantity
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Shape and string quantities from the original TEST allocation preflight;
|
||||
// customer, warehouse, order, SKU and resource identities are anonymized.
|
||||
export const transferSourceWireFixture = {
|
||||
customerId: 'fixture-customer',
|
||||
transfer: {
|
||||
id: 'fixture-transfer-id', transferNo: 'WT-FIXTURE', packageCount: 1,
|
||||
operatingWarehouseId: 'fixture-warehouse', status: 'APPROVED', needRelabel: true,
|
||||
items: [], cartons: [], pallets: [],
|
||||
sourceOrders: [{ lastlegOrderId: 'fixture-inbound', lastlegOrderNo: 'FL-FIXTURE', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE' }],
|
||||
relabelBindings: [{ operationType: 'SKU', customerCartonMark: 'BOX-FIXTURE', originalSku: 'SKU-FIXTURE-OLD', newSku: 'SKU-FIXTURE-NEW', quantity: 1 }],
|
||||
},
|
||||
balance: {
|
||||
id: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse',
|
||||
destinationWarehouseCode: 'ABE2', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE',
|
||||
skuCode: 'SKU-FIXTURE-OLD', productNameZh: '合成测试货物',
|
||||
totalPackages: '4', availablePackages: '3', holdPackages: '1', allocatedPackages: '0', pickedPackages: '0',
|
||||
},
|
||||
positions: [
|
||||
{ id: 'fixture-held-position', balanceId: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE', palletId: 'fixture-held-pallet', palletNo: 'DP-FIXTURE-0017', warehouseLocationCode: 'LOC-FIXTURE', totalPackages: '1', availablePackages: '0', holdPackages: '1', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', status: 'OCCUPIED' },
|
||||
{ id: 'fixture-available-position', balanceId: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE', palletId: 'fixture-available-pallet', palletNo: 'DP-FIXTURE-0018', warehouseLocationCode: 'LOC-FIXTURE', totalPackages: '3', availablePackages: '3', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', status: 'OCCUPIED' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listWarehouseTransferSources } from './warehouseTransferSources'
|
||||
import { getWarehouseTransfer, type WarehouseTransferDetailDto } from './warehouseTransfers'
|
||||
import { transferSourceWireFixture } from './warehouseTransferSources.fixture'
|
||||
|
||||
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: { get: vi.fn() } }))
|
||||
const transfer: WarehouseTransferDetailDto = { id: 'transfer', transferNo: 'WT-1', operatingWarehouseId: 'WH-1', needRelabel: true, packageCount: 1, status: 'APPROVED', items: [], cartons: [], pallets: [], relabelBindings: [{ operationType: 'SKU', customerCartonMark: 'BOX', originalSku: 'OLD', newSku: 'NEW', quantity: 1 }], sourceOrders: [{ lastlegOrderId: 'tail', inboundOrderId: 'IN-ID', inboundCode: 'IN-1' }] }
|
||||
const balance = { id: 'B-1', customerId: 'C-1', overseasWarehouseId: 'WH-1', inboundOrderId: 'IN-ID', inboundCode: 'IN-1', skuCode: 'OLD', destinationWarehouseCode: 'ABE2', productNameZh: '货物' }
|
||||
const position = { id: 'P-1', balanceId: 'B-1', customerId: 'C-1', overseasWarehouseId: 'WH-1', inboundOrderId: 'IN-ID', inboundCode: 'IN-1', palletId: 'PAL-ID', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, status: 'OCCUPIED' }
|
||||
const envelope = (data: unknown) => ({ code: '200', data })
|
||||
const page = (records: unknown[], current = 1, total = records.length) => envelope({ records, page: current, size: 200, total, pages: Math.ceil(total / 200) })
|
||||
describe('confirmed warehouse transfer source reads', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
it('normalizes the real original-ticket string quantities before source eligibility and package totals', async () => {
|
||||
const fixture = transferSourceWireFixture
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(fixture.transfer)).mockResolvedValueOnce(page([{ id: fixture.balance.id, skuCode: fixture.balance.skuCode }])).mockResolvedValueOnce(envelope(fixture.balance)).mockResolvedValueOnce(envelope(fixture.positions))
|
||||
const detail = await getWarehouseTransfer(fixture.customerId, fixture.transfer.transferNo)
|
||||
const result = await listWarehouseTransferSources(fixture.customerId, detail)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.candidates[0]).toMatchObject({ availablePackages: 0, canSelect: false })
|
||||
expect(result.candidates[1]).toMatchObject({ availablePackages: 3, canSelect: true })
|
||||
expect(detail.packageCount).toBe(1)
|
||||
expect(typeof detail.packageCount).toBe('number')
|
||||
})
|
||||
it('reads representative detail and actual positions with customer scope, accepts OCCUPIED available stock at original ABE2', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'OLD' }])).mockResolvedValueOnce(envelope(balance)).mockResolvedValueOnce(envelope([position, { ...position, id: 'P-HOLD', availablePackages: 0, holdPackages: 1 }]))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.candidates[0]).toMatchObject({ palletNo: 'DP-1', availablePackages: 3, canSelect: true })
|
||||
expect(result.candidates[1]).toMatchObject({ canSelect: false, blockReason: '当前无可分配箱数' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: { customerId: 'C-1', overseasWarehouseId: 'WH-1', inboundCode: 'IN-1', page: 1, size: 200 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/balances/B-1', { params: { customerId: 'C-1' } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/inventory/balances/B-1/positions', { params: { customerId: 'C-1' } })
|
||||
})
|
||||
it.each(['customerId', 'overseasWarehouseId', 'inboundOrderId', 'inboundCode', 'skuCode'] as const)('marks a mismatched representative %s as pending investigation, without treating it as zero inventory', async key => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'OLD' }])).mockResolvedValueOnce(envelope({ ...balance, [key]: 'WRONG' }))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(result.candidates).toEqual([]); expect(result.issues[0]).toContain('待核查')
|
||||
expect(http.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('does not allow MIXED to stand in for actual old SKU', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'MIXED' }])).mockResolvedValueOnce(envelope({ ...balance, skuCode: 'MIXED' })).mockResolvedValueOnce(envelope([position]))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(result.candidates[0]).toMatchObject({ skuCode: '—', canSelect: false })
|
||||
expect(result.candidates[0]?.blockReason).toContain('具体旧 SKU')
|
||||
})
|
||||
it('checks actual old SKU against the request bindings and rejects missing pallet business identity', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'OTHER' }])).mockResolvedValueOnce(envelope({ ...balance, skuCode: 'OTHER' })).mockResolvedValueOnce(envelope([position, { ...position, id: 'P-NO-CODE', palletNo: null }]))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(result.candidates.every(x => !x.canSelect)).toBe(true)
|
||||
expect(result.candidates[1]?.blockReason).toContain('托盘业务码')
|
||||
})
|
||||
it.each(['customerId', 'overseasWarehouseId', 'balanceId', 'inboundOrderId', 'inboundCode'] as const)('rejects an actual position with mismatched %s', async key => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'OLD' }])).mockResolvedValueOnce(envelope(balance)).mockResolvedValueOnce(envelope([{ ...position, [key]: 'WRONG' }]))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(result.candidates).toEqual([]); expect(result.issues[0]).toContain('待核查')
|
||||
})
|
||||
it('loads the complete second page and rejects changed pagination instead of truncating at 200', async () => {
|
||||
vi.mocked(http.get).mockImplementation(async (url, config) => {
|
||||
if (url === '/platform/v1/last-leg/inventory/balances') return page(Number(config?.params.page) === 1 ? Array.from({ length: 200 }, (_, i) => ({ id: `B-${i}`, skuCode: 'OLD' })) : [{ id: 'B-200', skuCode: 'OLD' }], Number(config?.params.page), 201)
|
||||
if (url.endsWith('/positions')) return envelope([])
|
||||
return envelope({ ...balance, id: url.split('/').at(-1) })
|
||||
})
|
||||
await listWarehouseTransferSources('C-1', transfer)
|
||||
expect(vi.mocked(http.get).mock.calls.filter(([url]) => url === '/platform/v1/last-leg/inventory/balances')).toHaveLength(2)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/balances/B-200', { params: { customerId: 'C-1' } })
|
||||
vi.mocked(http.get).mockReset().mockResolvedValueOnce(page([], 1, 1))
|
||||
await expect(listWarehouseTransferSources('C-1', transfer)).rejects.toThrow('分页发生变化或不完整')
|
||||
})
|
||||
it('requires bound sources and old SKU before any inventory call', async () => {
|
||||
await expect(listWarehouseTransferSources('C-1', { ...transfer, sourceOrders: [] })).rejects.toThrow('绑定入仓')
|
||||
await expect(listWarehouseTransferSources('C-1', { ...transfer, relabelBindings: [] })).rejects.toThrow('旧 SKU')
|
||||
expect(http.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { InventoryBalanceDto, InventoryPositionDto } from './inventory'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload, type OverseasPageResult } from './lastMileOrders'
|
||||
import type { WarehouseTransferDetailDto } from './warehouseTransfers'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
export interface WarehouseTransferSourceCandidate {
|
||||
inventoryPositionId: string
|
||||
palletId: string
|
||||
inboundCode: string
|
||||
skuCode: string
|
||||
productNameZh: string
|
||||
palletNo: string
|
||||
warehouseLocationCode: string
|
||||
availablePackages: number
|
||||
canSelect: boolean
|
||||
blockReason: string
|
||||
}
|
||||
type ScopedPosition = Omit<InventoryPositionDto, 'availablePackages'> & { customerId: string; availablePackages: number | string }
|
||||
export async function listWarehouseTransferSources(customerId: string, transfer: WarehouseTransferDetailDto) {
|
||||
const sources = new Map((transfer.sourceOrders || []).map(row => [row.inboundOrderId, row]))
|
||||
if (!customerId || !transfer.operatingWarehouseId || !sources.size || [...sources.values()].some(row => !row.inboundOrderId || !row.inboundCode)) {
|
||||
throw new ApiResponseError('本票绑定入仓或操作仓资料不完整,需核查后选择库存来源。', 'TRANSFER_SOURCE_INCOMPLETE')
|
||||
}
|
||||
const oldSkus = new Set(transfer.relabelBindings.map(row => row.originalSku?.trim()).filter(Boolean))
|
||||
if (transfer.needRelabel && !oldSkus.size) throw new ApiResponseError('本票缺少明确旧 SKU,需核查换标要求后选择来源。', 'TRANSFER_SKU_MISSING')
|
||||
const candidates = new Map<string, WarehouseTransferSourceCandidate>(), issues: string[] = []
|
||||
for (const source of sources.values()) {
|
||||
const representedIds = new Set<string>()
|
||||
let currentPage = 1, total = -1, recordsRead = 0
|
||||
do {
|
||||
const page: OverseasPageResult<InventoryBalanceDto> = normalizeOverseasPage(unwrapApiResponse(await http.get<ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>, ApiEnvelope<OverseasPagePayload<InventoryBalanceDto>>>(
|
||||
'/platform/v1/last-leg/inventory/balances', { params: { customerId, overseasWarehouseId: transfer.operatingWarehouseId, inboundCode: source.inboundCode, page: currentPage, size: 200 } },
|
||||
)))
|
||||
if (page.page !== currentPage || page.size !== 200 || !Number.isSafeInteger(page.total) || page.total < 0 || page.pages !== Math.ceil(page.total / 200) || (total >= 0 && total !== page.total) || page.records.length !== Math.min(200, Math.max(0, page.total - (currentPage - 1) * 200))) {
|
||||
throw new ApiResponseError('来源库存分页发生变化或不完整,请重新加载,暂不能分配。', 'TRANSFER_SOURCE_PAGE_CHANGED')
|
||||
}
|
||||
total = page.total; recordsRead += page.records.length
|
||||
for (const representative of page.records) {
|
||||
if (!representative.id || representedIds.has(representative.id)) throw new ApiResponseError('来源库存分页含重复或缺失代表记录,请核查。', 'TRANSFER_SOURCE_AMBIGUOUS')
|
||||
representedIds.add(representative.id)
|
||||
const base = `/platform/v1/last-leg/inventory/balances/${encodeURIComponent(representative.id)}`
|
||||
const balance = unwrapApiResponse(await http.get<ApiEnvelope<InventoryBalanceDto>, ApiEnvelope<InventoryBalanceDto>>(base, { params: { customerId } }))
|
||||
if (balance.id !== representative.id || balance.customerId !== customerId || balance.overseasWarehouseId !== transfer.operatingWarehouseId || balance.inboundOrderId !== source.inboundOrderId || balance.inboundCode !== source.inboundCode || (representative.skuCode && balance.skuCode !== representative.skuCode)) {
|
||||
issues.push(`入仓码 ${source.inboundCode} 的库存代表与本票来源不一致,待核查;未使用该聚合行分配。`)
|
||||
continue
|
||||
}
|
||||
const positions = unwrapApiResponse(await http.get<ApiEnvelope<ScopedPosition[]>, ApiEnvelope<ScopedPosition[]>>(`${base}/positions`, { params: { customerId } }))
|
||||
for (const position of positions) {
|
||||
if (position.customerId !== customerId || position.overseasWarehouseId !== transfer.operatingWarehouseId || position.balanceId !== balance.id || position.inboundOrderId !== source.inboundOrderId || position.inboundCode !== source.inboundCode || !position.id || candidates.has(position.id)) {
|
||||
issues.push(`入仓码 ${source.inboundCode} 的库存位置归属不明确或重复,待核查。`)
|
||||
continue
|
||||
}
|
||||
const sku = balance.skuCode?.trim() || ''
|
||||
const availablePackages = normalizeWarehouseTransferQuantity(position.availablePackages, '库存位置可分配箱数')
|
||||
const reason = !position.palletId || !position.palletNo ? '缺少完整托盘业务码,待核查'
|
||||
: availablePackages <= 0 ? '当前无可分配箱数'
|
||||
: transfer.needRelabel && (!sku || sku === 'MIXED' || !oldSkus.has(sku)) ? '来源不能证明本票具体旧 SKU,待核查' : ''
|
||||
candidates.set(position.id, { inventoryPositionId: position.id, palletId: position.palletId || '', inboundCode: source.inboundCode || '', skuCode: sku === 'MIXED' ? '—' : sku || '—', productNameZh: balance.productNameZh || '—', palletNo: position.palletNo || '—', warehouseLocationCode: position.warehouseLocationCode || '—', availablePackages, canSelect: !reason, blockReason: reason })
|
||||
}
|
||||
}
|
||||
currentPage++
|
||||
} while (recordsRead < total)
|
||||
}
|
||||
return { candidates: [...candidates.values()], issues }
|
||||
}
|
||||
@@ -1,15 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer } from './warehouseTransfers'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } from './warehouseTransfers'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('warehouseTransfers API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('uses the resource-role receiving options contract, never warehouse master list', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 'NY', customerId: 0, warehouseCode: 'NY', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }], total: 1, page: 1, size: 200, pages: 1 } })
|
||||
expect((await listTransferReceivingWarehouses()).records[0]?.customerId).toBe('0')
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/warehouse-admin/overseas-receiving-options', { params: { page: 1, size: 200 } })
|
||||
})
|
||||
it('keeps outbound and receipt original bodies and customer scope without inventing receipt rows', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT/1', packageCount: '2', status: 'PARTIALLY_RECEIVED', allocations: [] } })
|
||||
const outbound = { idempotencyKey: 'out-original' }
|
||||
await outboundWarehouseTransfer('customer', 'WT/1', outbound)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/warehouse-transfers/WT%2F1/outbound', outbound, { params: { customerId: 'customer' } })
|
||||
const receipt = { sourceInventoryPositionId: 'position', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约仓', destinationWarehouseCode: 'ABE4', destinationPalletId: 'pallet', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'location', warehouseLocationCode: 'LOC-NY', batchNo: 'WT/1', idempotencyKey: 'receipt-original' }
|
||||
await receiveWarehouseTransfer('customer', 'WT/1', receipt)
|
||||
await receiveWarehouseTransfer('customer', 'WT/1', receipt)
|
||||
expect(vi.mocked(http.post).mock.calls[1]).toEqual(vi.mocked(http.post).mock.calls[2])
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/warehouse-transfers/WT%2F1/receipts', receipt, { params: { customerId: 'customer' } })
|
||||
})
|
||||
|
||||
it('normalizes detail, review and allocation box counts at the API boundary', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: '1' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: '1' } })
|
||||
expect((await getWarehouseTransfer('customer-1', 'WT-1')).packageCount).toBe(1)
|
||||
expect((await reviewWarehouseTransfer('customer-1', 'WT-1', 'APPROVE')).packageCount).toBe(1)
|
||||
expect((await allocateWarehouseTransfer('customer-1', 'WT-1', { idempotencyKey: 'fixed', allocations: [{ inventoryPositionId: 'position', palletId: 'pallet', packageCount: 1 }] })).packageCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects an ambiguous transfer total instead of passing it into strict selected-package comparison', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: true } })
|
||||
await expect(getWarehouseTransfer('customer-1', 'WT-1')).rejects.toThrow('本票转运箱数返回格式无效')
|
||||
})
|
||||
|
||||
it('preserves the body idempotency key, exact source positions and selected customer on replay', async () => {
|
||||
const value = { transferNo: 'WT-1', packageCount: 2, status: 'WAIT_RELABEL', relabelNo: 'RL-1', items: [], cartons: [], relabelBindings: [] }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: value })
|
||||
const payload = { idempotencyKey: 'allocation-fixed', allocations: [{ inventoryPositionId: '2100000000000000001', palletId: '2100000000000000002', packageCount: 2 }] }
|
||||
expect(await allocateWarehouseTransfer('20001', 'WT-1', payload)).toEqual(value)
|
||||
await allocateWarehouseTransfer('20001', 'WT-1', payload)
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/warehouse-transfers/WT-1/allocations', payload, { params: { customerId: '20001' } })
|
||||
expect(vi.mocked(http.post).mock.calls[1]).toEqual(vi.mocked(http.post).mock.calls[0])
|
||||
})
|
||||
|
||||
it('keeps rejected source allocation commands as errors', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '聚合库存不能证明本票旧 SKU' })
|
||||
await expect(allocateWarehouseTransfer('20001', 'WT-1', { idempotencyKey: 'original-key', allocations: [{ inventoryPositionId: '1', palletId: '2', packageCount: 1 }] })).rejects.toThrow('聚合库存不能证明本票旧 SKU')
|
||||
})
|
||||
|
||||
it('reuses the original command after an unknown transport result without dropping source rows', async () => {
|
||||
const payload = { idempotencyKey: 'same-key-after-response-loss', allocations: [{ inventoryPositionId: '2100000000000000001', palletId: '2100000000000000002', packageCount: 1 }, { inventoryPositionId: '2100000000000000003', palletId: '2100000000000000004', packageCount: 2 }] }
|
||||
const before = structuredClone(payload)
|
||||
vi.mocked(http.post).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', packageCount: 3, status: 'WAIT_RELABEL', relabelNo: 'RL-SAME' } })
|
||||
await expect(allocateWarehouseTransfer('customer-1', 'WT-1', payload)).rejects.toThrow('response lost')
|
||||
expect(payload).toEqual(before)
|
||||
expect(await allocateWarehouseTransfer('customer-1', 'WT-1', payload)).toMatchObject({ status: 'WAIT_RELABEL', relabelNo: 'RL-SAME' })
|
||||
expect(vi.mocked(http.post).mock.calls[0]).toEqual(vi.mocked(http.post).mock.calls[1])
|
||||
expect(payload).toEqual(before)
|
||||
})
|
||||
|
||||
it('always scopes platform list and detail requests to the selected customer', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 10, total: 0, pages: 0 } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', packageCount: 2, items: [], cartons: [], relabelBindings: [] } })
|
||||
await listWarehouseTransfers('20001', { page: 1, size: 10, status: 'PENDING_REVIEW' })
|
||||
await getWarehouseTransfer('20001', 'WT-1')
|
||||
expect(vi.mocked(http.get).mock.calls[0]).toEqual(['/platform/v1/last-leg/warehouse-transfers', { params: { page: 1, size: 10, status: 'PENDING_REVIEW', customerId: '20001' } }])
|
||||
@@ -17,7 +72,7 @@ describe('warehouseTransfers API', () => {
|
||||
})
|
||||
|
||||
it('posts the real review decision with customer scope', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: 2, items: [], cartons: [], relabelBindings: [] } })
|
||||
await reviewWarehouseTransfer('20001', 'WT-1', 'APPROVE', '资料无误')
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/last-leg/warehouse-transfers/WT-1/review',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
import type { ReceivingOverseasWarehouseDto } from './pallets'
|
||||
|
||||
export interface WarehouseTransferQuery {
|
||||
page: number
|
||||
@@ -84,6 +86,91 @@ export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto
|
||||
relabelBindings: WarehouseTransferRelabelBindingDto[]
|
||||
customerConfirmedAt?: string | null
|
||||
actualShipAt?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
sourceOrders?: Array<{ lastlegOrderId: string; lastlegOrderNo?: string | null; inboundOrderId: string; inboundCode?: string | null }>
|
||||
allocations?: Array<{ inventoryPositionId: string; inboundOrderId: string; inboundCode?: string | null; palletId: string; palletNo?: string | null; packageCount: number; status: string; destinationInventoryPositionId?: string | null; skuCode?: string | null }>
|
||||
receiptCommands?: WarehouseTransferReceiptCommandDto[]
|
||||
}
|
||||
|
||||
export interface WarehouseTransferReceiptCommandDto {
|
||||
sourceInventoryPositionId: string
|
||||
status: 'PREPARED' | 'RESERVED' | 'COMPLETED'
|
||||
requestHash: string
|
||||
request: ReceiveWarehouseTransferPayload
|
||||
target: { warehouseId: string; warehouseCode: string; warehouseName: string; palletId: string; palletNo: string; locationId: string; locationCode: string; fbaId?: string | null; fbaCode: string; fbaName?: string | null }
|
||||
}
|
||||
|
||||
export interface AllocateWarehouseTransferPayload {
|
||||
idempotencyKey: string
|
||||
allocations: Array<{ inventoryPositionId: string; palletId: string; packageCount: number }>
|
||||
}
|
||||
|
||||
export interface OutboundWarehouseTransferPayload { idempotencyKey: string; remark?: string }
|
||||
export interface ReceiveWarehouseTransferPayload {
|
||||
sourceInventoryPositionId: string
|
||||
destinationOverseasWarehouseId: string
|
||||
destinationOverseasWarehouseCode: string
|
||||
destinationOverseasWarehouseName: string
|
||||
destinationWarehouseId?: string
|
||||
destinationWarehouseCode: string
|
||||
destinationWarehouseName?: string
|
||||
destinationPalletId: string
|
||||
destinationPalletNo: string
|
||||
warehouseLocationId: string
|
||||
warehouseLocationCode: string
|
||||
batchNo: string
|
||||
idempotencyKey: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export async function outboundWarehouseTransfer(customerId: string, transferNo: string, payload: OutboundWarehouseTransferPayload) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/outbound`, payload, { params: { customerId } },
|
||||
)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function receiveWarehouseTransfer(customerId: string, transferNo: string, payload: ReceiveWarehouseTransferPayload) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/receipts`, payload, { params: { customerId } },
|
||||
)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listTransferReceivingWarehouses() {
|
||||
const records: ReceivingOverseasWarehouseDto[] = []
|
||||
let total: number | undefined
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<ReceivingOverseasWarehouseDto>>, ApiEnvelope<OverseasPagePayload<ReceivingOverseasWarehouseDto>>>(
|
||||
'/platform/v1/warehouse-admin/overseas-receiving-options', { params: { page, size: 200 } },
|
||||
)
|
||||
const value = normalizeOverseasPage(unwrapApiResponse(response))
|
||||
if (total !== undefined && total !== value.total) throw new Error('收货仓列表已变化,请重新查询')
|
||||
total = value.total
|
||||
records.push(...value.records.map(w => ({ ...w, id: String(w.id), customerId: String(w.customerId) })))
|
||||
if (records.length === total) {
|
||||
if (new Set(records.map(w => w.id)).size !== records.length || records.some(w => w.customerId !== '0' || w.warehouseType !== 'OVERSEAS' || w.status !== 'ACTIVE')) throw new Error('收货仓返回范围不符合要求')
|
||||
return { records }
|
||||
}
|
||||
if (!value.records.length || records.length > total) throw new Error('收货仓分页不完整,请重新查询')
|
||||
}
|
||||
throw new Error('收货仓分页超出查询限制')
|
||||
}
|
||||
|
||||
type WarehouseTransferDetailWire = Omit<WarehouseTransferDetailDto, 'packageCount'> & { packageCount: number | string }
|
||||
function normalizeTransferDetail(value: WarehouseTransferDetailWire): WarehouseTransferDetailDto {
|
||||
return { ...value, packageCount: normalizeWarehouseTransferQuantity(value.packageCount, '本票转运箱数') }
|
||||
}
|
||||
|
||||
/** The caller must use confirmed source positions, never aggregate inventory rows. */
|
||||
export async function allocateWarehouseTransfer(customerId: string, transferNo: string, payload: AllocateWarehouseTransferPayload) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/allocations`,
|
||||
payload,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) {
|
||||
@@ -95,11 +182,11 @@ export async function listWarehouseTransfers(customerId: string, query: Warehous
|
||||
}
|
||||
|
||||
export async function getWarehouseTransfer(customerId: string, transferNo: string) {
|
||||
const response = await http.get<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
const response = await http.get<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function reviewWarehouseTransfer(
|
||||
@@ -108,10 +195,10 @@ export async function reviewWarehouseTransfer(
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
remark?: string,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/review`,
|
||||
{ decision, remark },
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CoveredService, FeeCoveragePreview, FeeCoverageSelection, OriginalFeeSource } from '../feeCoverageTypes'
|
||||
import { coveredServiceLabels } from '../feeCoverageTypes'
|
||||
const props = defineProps<{ preview: FeeCoveragePreview | null; disabled?: boolean; loading?: boolean }>()
|
||||
const selection = defineModel<FeeCoverageSelection[]>({ default: () => [] })
|
||||
function selected(id: string) { return selection.value.find(row => row.financeFeeId === id)?.services || [] }
|
||||
function inboundCode(source: OriginalFeeSource) { return props.preview?.cargo.find(row => row.sourceInboundId === source.sourceInboundId)?.inboundCode || source.inboundCode || '—' }
|
||||
function change(id: string, services: CoveredService[]) {
|
||||
const source = props.preview?.feeSources.find(row => row.financeFeeId === id)
|
||||
if (!source) return
|
||||
selection.value = [...selection.value.filter(row => row.financeFeeId !== id), ...(services.length ? [{ financeFeeId: id, sourceVersion: source.sourceVersion, sourceHash: source.sourceHash, services: [...services].sort() }] : [])].sort((a, b) => a.financeFeeId.localeCompare(b.financeFeeId))
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="原运费服务覆盖" class="coverage-panel">
|
||||
<h3>原运费服务覆盖</h3>
|
||||
<p>仅勾选已与客户明确约定包含的服务。原费用金额供核对,不是本次新增金额;同托多票须分别确认。</p>
|
||||
<p v-if="loading">正在读取原费用…</p>
|
||||
<p v-else-if="!preview">原费用尚未读取,请重新打开后核对。</p>
|
||||
<p v-else-if="!preview.sourceComplete">{{ preview.blockReason }};当前不能引用原费用。</p>
|
||||
<p v-else-if="!preview?.feeSources.length">暂无可引用的已确认原费用。</p>
|
||||
<div v-for="source in preview?.feeSources || []" :key="source.financeFeeId" class="coverage-row">
|
||||
<span>{{ inboundCode(source) }} · {{ source.feeItemName || '—' }} · {{ source.currency }} {{ source.amount }} · 版本 {{ source.sourceVersion }}</span>
|
||||
<el-checkbox-group :model-value="selected(source.financeFeeId)" :disabled="disabled || !preview?.sourceComplete" @update:model-value="change(source.financeFeeId, $event as CoveredService[])">
|
||||
<el-checkbox v-for="(label, code) in coveredServiceLabels" :key="code" :value="code">已含{{ label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.coverage-panel { margin-bottom: 12px; }
|
||||
.coverage-panel p { margin: 6px 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.coverage-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 0; border-bottom: 1px solid var(--el-border-color-lighter); font-size: 12px; }
|
||||
.coverage-row > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
|
||||
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
|
||||
import { getPallet } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
|
||||
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const field = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
|
||||
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) })
|
||||
} })
|
||||
const quantity = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
|
||||
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', Number((event.target as HTMLInputElement).value)) })
|
||||
} })
|
||||
function snapshot(number = 'EX-1', version = 1, remaining = 4): ExceptionDetail {
|
||||
return normalizeExceptionDetail({ order: { id: '1', exceptionNo: number, customerId: 'C', exceptionType: 'RETURN', returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
overseasWarehouseId: '92', status: remaining ? 'PROCESSING' : 'COMPLETED', version: String(version), allowedActions: remaining ? ['COMPLETE_EXCEPTION'] : [],
|
||||
reason: '拒收', exceptionAt: '', updatedAt: '', evidenceFileIds: ['91'] },
|
||||
returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [], returnReceipts: [] } as unknown as ExceptionDetail)
|
||||
}
|
||||
function open(realControls = false) { return mount(ExceptionProcessingPanel, { props: { customerId: 'C', exceptionNo: 'EX-1', active: true }, global: {
|
||||
components: { ElForm, ElFormItem, ElInput, ElInputNumber },
|
||||
directives: { loading: () => {} }, stubs: { ElDescriptions: shell, ElDescriptionsItem: shell, ElForm: realControls ? false : shell, ElFormItem: realControls ? false : shell,
|
||||
ElInput: realControls ? false : field, ElInputNumber: realControls ? false : quantity, ElSelect: field, ElOption: true, RouterLink: { template: '<a><slot /></a>' }, ElButton: { template: '<button><slot /></button>' } },
|
||||
} }) }
|
||||
async function fill(wrapper: ReturnType<typeof open>, amount = 2) {
|
||||
if (wrapper.find('[data-test="history-0"]').exists()) await wrapper.get('[data-test="history-0"]').setValue(true)
|
||||
await wrapper.get('[data-test="notes"]').setValue('本批实际收到')
|
||||
await wrapper.get('[data-test="quantity-0"]').setValue(amount)
|
||||
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
|
||||
}
|
||||
|
||||
it('completed detail retains multiple receipt batches and redispatches as read-only history without commands', async () => {
|
||||
const completed = snapshot('EX-1', 7, 0)
|
||||
completed.returnReceipts = [
|
||||
{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '2026-10-03T23:24:13', redispatches: [
|
||||
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 1, pickedPackages: 1, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
|
||||
{ dispatchNo: 'DS-2', status: 'PENDING_DEPARTURE', selectedPackages: 1, pickedPackages: 0, deliveries: [] },
|
||||
] },
|
||||
{ receiptNo: 'RSR-2', receivedPackages: 1, receivedAt: '2026-10-03T23:25:13', redispatches: [] },
|
||||
]
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
|
||||
const wrapper = open(); await flushPromises()
|
||||
for (const text of ['RSR-1', 'RSR-2', 'DS-1', 'DS-2', 'DL-1', '尚未关联重派排车', '已发车', '待发车']) expect(wrapper.text()).toContain(text)
|
||||
expect(wrapper.findAll('.exception-return-batch')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-test="start"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="cancel"]').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('related business numbers remain visible without independent list permissions, but no navigation links are offered', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const completed = snapshot('EX-1', 7, 0)
|
||||
completed.returnReceipts = [{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '', redispatches: [
|
||||
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 2, pickedPackages: 2, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
|
||||
] }]
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
|
||||
const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('DS-1'); expect(wrapper.text()).toContain('DL-1')
|
||||
expect(wrapper.find('.exception-return-history a').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); can.mockReturnValue(true)
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot())
|
||||
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
|
||||
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
|
||||
})
|
||||
it('real receipt quantity control becomes accessible after starting processing', async () => {
|
||||
const pending = snapshot('EX-1', 0, 2)
|
||||
pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = []
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(pending).mockResolvedValueOnce(snapshot('EX-1', 1, 2))
|
||||
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 1, status: 'PROCESSING', restoredPackages: 0, replayed: false })
|
||||
const wrapper = open(true); try { await flushPromises()
|
||||
await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises()
|
||||
const input = wrapper.getComponent(ElInputNumber).get('input')
|
||||
expect((input.element as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input.attributes('aria-disabled')).toBe('false')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('real quantity control reflects in-flight lock and unlocks after a partial receipt reload', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot('EX-1', 1, 2)).mockResolvedValueOnce(snapshot('EX-1', 2, 1))
|
||||
let finish!: (value: Awaited<ReturnType<typeof commandException>>) => void
|
||||
vi.mocked(commandException).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = open(true); try { await flushPromises()
|
||||
await wrapper.get('[data-test="history-0"]').setValue(true)
|
||||
wrapper.getComponent(ElInputNumber).vm.$emit('update:modelValue', 1)
|
||||
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.getComponent(ElInputNumber).get('input').element as HTMLInputElement).disabled).toBe(true)
|
||||
expect(wrapper.getComponent(ElInputNumber).get('input').attributes('aria-disabled')).toBe('true')
|
||||
finish({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 1, replayed: false }); await flushPromises()
|
||||
const input = wrapper.getComponent(ElInputNumber).get('input')
|
||||
expect((input.element as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input.attributes('aria-disabled')).toBe('false')
|
||||
expect(wrapper.text()).toContain('剩余可收 1 箱')
|
||||
expect(commandException).toHaveBeenCalledTimes(1)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('submits two actual batches using refreshed remaining sources and independent command keys', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 2, 2)).mockResolvedValueOnce(snapshot('EX-1', 3, 0))
|
||||
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: false })
|
||||
.mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 3, status: 'COMPLETED', restoredPackages: 2, replayed: false })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('仍有待收货物'); expect(wrapper.text()).toContain('剩余可收 2 箱')
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(commandException).mock.calls[0]!, second = vi.mocked(commandException).mock.calls[1]!
|
||||
expect(first.slice(0, 3)).toEqual(['C', 'EX-1', 'complete'])
|
||||
expect(first[3].receipts?.[0]).toEqual({ outboundPalletItemId: '10', inventoryPositionId: '20', receivedPackages: 2, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' })
|
||||
expect(first[3].expectedVersion).toBe(1); expect(second[3].expectedVersion).toBe(2)
|
||||
expect(getPallet).toHaveBeenCalledTimes(2); expect(getOverseasLocation).toHaveBeenCalledTimes(2)
|
||||
expect(getOverseasLocation).toHaveBeenNthCalledWith(1, '92', '40')
|
||||
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
|
||||
expect(wrapper.text()).toContain('已保留原请求')
|
||||
await wrapper.get('[data-test="notes"]').setValue('changed')
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('has no processing or upload controls without independent permission', async () => {
|
||||
can.mockReturnValue(false); const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('ignores an old detail response after switching exceptions', async () => {
|
||||
let resolve!: (value: ExceptionDetail) => void
|
||||
vi.mocked(getExceptionDetail).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(snapshot('EX-2', 2, 1))
|
||||
const wrapper = open(); await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
|
||||
resolve(snapshot('EX-1')); await flushPromises()
|
||||
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.text()).not.toContain('EX-1'); wrapper.unmount()
|
||||
})
|
||||
it('a late successful command never replaces the new exception detail', async () => {
|
||||
let resolve!: (value: Awaited<ReturnType<typeof commandException>>) => void
|
||||
vi.mocked(commandException).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click')
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-2', 2, 1))
|
||||
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
|
||||
resolve({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 2, replayed: false }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.emitted('changed')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('starts or cancels only when returned actions permit it, with the authoritative version', async () => {
|
||||
const pending = snapshot(); pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING', 'CANCEL_EXCEPTION']; pending.order.version = 4
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(pending)
|
||||
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'PROCESSING', restoredPackages: 0, replayed: false })
|
||||
const wrapper = open(); await flushPromises()
|
||||
await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[0]![2]).toBe('start-processing')
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].expectedVersion).toBe(4)
|
||||
await wrapper.get('[data-test="notes"]').setValue('退回申请有误')
|
||||
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'CANCELLED', restoredPackages: 0, replayed: false })
|
||||
await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].reason).toBe('退回申请有误')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uploads owned evidence and includes the resulting file in the completion request', async () => {
|
||||
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'receipt.txt' })
|
||||
const wrapper = open(); await flushPromises()
|
||||
const input = wrapper.get('[data-test="evidence"]'), file = new File(['proof'], 'receipt.txt')
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(uploadExceptionEvidence).toHaveBeenCalledWith('C', 'EX-1', file)
|
||||
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toContain('991')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejects a target pallet whose real warehouse differs from the exception warehouse', async () => {
|
||||
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: 'OTHER', locationId: '40', palletCode: 'WRONG' } as Awaited<ReturnType<typeof getPallet>>)
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(getOverseasLocation).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses standard audit text for actual warehouse receipt when the optional note is empty', async () => {
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="notes"]').setValue('')
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].handlingResult).toBe('确认本次退回入库')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('allows cancellation and ordinary completion with standard audit text and still requires evidence', async () => {
|
||||
const state = snapshot(); state.order.status = 'PENDING'; state.order.allowedActions = ['CANCEL_EXCEPTION']; state.order.exceptionType = 'DAMAGED'
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state)
|
||||
const wrapper = open(); await flushPromises(); await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].reason).toBe('取消异常处理')
|
||||
const ordinary = snapshot('EX-2'); ordinary.order.exceptionType = 'DAMAGED'; ordinary.returnSources = []; ordinary.order.evidenceFileIds = []
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(ordinary)
|
||||
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click')
|
||||
expect(commandException).toHaveBeenCalledTimes(1)
|
||||
ordinary.order.evidenceFileIds = ['91']
|
||||
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
|
||||
await wrapper.get('[data-test="history-0"]').setValue(true)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].handlingResult).toBe('确认异常处理完成')
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].receipts).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
|
||||
let rejectFirst!: (reason: Error) => void
|
||||
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
||||
const wrapper = open(); await flushPromises(); await fill(wrapper)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const originalKey = vi.mocked(commandException).mock.calls[0]![3].idempotencyKey
|
||||
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(commandException).toHaveBeenCalledTimes(1)
|
||||
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
|
||||
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
|
||||
await fill(wrapper)
|
||||
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
|
||||
expect(newKey).not.toBe(originalKey)
|
||||
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('twenty-one historical files do not get resubmitted automatically with the new batch evidence', async () => {
|
||||
const history = snapshot(); history.order.exceptionType = 'DAMAGED'; history.returnSources = []
|
||||
history.order.evidenceFileIds = Array.from({ length: 21 }, (_, index) => String(100 + index))
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(history)
|
||||
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'new-evidence.txt' })
|
||||
const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('21 份历史证据')
|
||||
const input = wrapper.get('[data-test="evidence"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['proof'], 'new-evidence.txt')] })
|
||||
await input.trigger('change'); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
|
||||
import { getPallet, listPallets, type PalletDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
|
||||
const dispatchStatusLabels: Record<string, string> = { PENDING_DEPARTURE: '待发车', PICKING: '拣货中', READY_TO_DEPART: '待发车', DEPARTED: '已发车', IN_TRANSIT: '在途', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const dispatchStatusLabel = (status: string) => dispatchStatusLabels[status] || '—'
|
||||
const identity = computed(() => `${props.customerId}:${props.exceptionNo}`)
|
||||
const detail = ref<ExceptionDetail | null>(null), loading = ref(false), busy = ref(false), error = ref(''), message = ref('')
|
||||
type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity: number | undefined; target: string; palletNo: string; locationId: string; locationCode: string }
|
||||
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
|
||||
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
|
||||
const pendingByScope = reactive(new Map<string, Attempt>())
|
||||
const unknownUploads = reactive(new Set<string>())
|
||||
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
|
||||
const selectedHistory = ref<string[]>([])
|
||||
const inFlight = reactive(new Map<string, string>())
|
||||
const uploadingScopes = reactive(new Set<string>())
|
||||
const pending = computed(() => pendingByScope.get(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
|
||||
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
|
||||
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
|
||||
let sequence = 0
|
||||
const targetSequences = new Map<number, number>()
|
||||
const current = (ticket: number) => props.active && ticket === sequence
|
||||
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
|
||||
async function load() {
|
||||
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
|
||||
try {
|
||||
const result = await getExceptionDetail(props.customerId, props.exceptionNo)
|
||||
if (!current(ticket)) return
|
||||
if (result.order.exceptionNo !== props.exceptionNo || result.order.customerId !== props.customerId || !Number.isSafeInteger(result.order.version)) throw new Error('异常详情归属或版本无效,请重新加载')
|
||||
detail.value = result
|
||||
receipts.value = result.returnSources.map(row => ({ ...row, sourcePalletNo: row.palletNo || '—', quantity: undefined, target: '', palletNo: '', locationId: '', locationCode: '' }))
|
||||
evidence.value = [...(savedEvidence.get(identity.value) || [])]
|
||||
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '异常详情加载失败').message }
|
||||
finally { if (current(ticket)) loading.value = false }
|
||||
}
|
||||
async function searchTargets(keyword: string) {
|
||||
const ticket = sequence, warehouse = detail.value?.order.overseasWarehouseId
|
||||
if (!warehouse || locked.value || !canProcess.value) return
|
||||
try {
|
||||
const result = await listPallets(warehouse, { page: 1, size: 100, keyword })
|
||||
if (current(ticket)) targets.value = result.records.filter(row => String(row.warehouseId) === String(warehouse) && !!row.locationId && row.status !== 'SCRAPPED')
|
||||
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '目标托盘加载失败').message }
|
||||
}
|
||||
function onTargetVisibility(visible: boolean) {
|
||||
if (visible) void searchTargets('')
|
||||
}
|
||||
function onTargetChange(index: number, value: unknown) {
|
||||
void chooseTarget(index, String(value))
|
||||
}
|
||||
async function chooseTarget(index: number, palletId: string) {
|
||||
const ticket = sequence, row = receipts.value[index], warehouse = detail.value?.order.overseasWarehouseId
|
||||
if (!row || !warehouse || locked.value) return
|
||||
const targetTicket = (targetSequences.get(index) || 0) + 1; targetSequences.set(index, targetTicket)
|
||||
row.target = palletId; row.palletNo = ''; row.locationId = ''; row.locationCode = ''
|
||||
const valid = () => current(ticket) && targetSequences.get(index) === targetTicket
|
||||
try {
|
||||
const pallet = await getPallet(warehouse, palletId)
|
||||
if (!valid()) return
|
||||
if (String(pallet.warehouseId) !== String(warehouse) || !pallet.locationId || String(pallet.id) !== palletId) throw new Error('托盘仓库或库位不匹配')
|
||||
const location = await getOverseasLocation(warehouse, String(pallet.locationId))
|
||||
if (!valid()) return
|
||||
if (String(location.id) !== String(pallet.locationId) || String(location.warehouseId) !== String(warehouse) || !location.locationCode)
|
||||
throw new Error('目标托盘实际库位归属不匹配')
|
||||
row.palletNo = pallet.palletCode; row.locationId = String(location.id); row.locationCode = location.locationCode
|
||||
} catch (cause) { if (valid()) error.value = getApiErrorDetails(cause, '托盘库位核对失败').message }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]
|
||||
if (!file || !canProcess.value || locked.value) return
|
||||
const ticket = sequence, scope = identity.value, customer = props.customerId, number = props.exceptionNo
|
||||
uploadingScopes.add(scope)
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const result = await uploadExceptionEvidence(customer, number, file)
|
||||
if (!result || !/^[1-9]\d*$/.test(String(result.fileId)) || !result.fileName) throw new Error('证据上传结果无法核对')
|
||||
const fileResult = { fileId: String(result.fileId), fileName: result.fileName }
|
||||
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
|
||||
if (current(ticket)) evidence.value.push(fileResult)
|
||||
} catch (cause) {
|
||||
if (!definite(cause)) unknownUploads.add(scope)
|
||||
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
|
||||
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
|
||||
}
|
||||
async function execute(action: ExceptionAction) {
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
|
||||
if (pending.value && pending.value.action !== action) return
|
||||
if (!pending.value) {
|
||||
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
|
||||
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
|
||||
const selected = receipts.value.filter(row => Number(row.quantity) > 0)
|
||||
if (action === 'complete' && warehouseReturn.value && (!selected.length || selected.some(row => !Number.isSafeInteger(row.quantity) || Number(row.quantity) > row.maxReturnPackages || !row.palletNo || !row.locationId || !row.locationCode))) {
|
||||
error.value = '请按剩余可收量填写本次实收,并选择已核对真实库位的目标托盘'; return
|
||||
}
|
||||
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
|
||||
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
|
||||
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
|
||||
if (action === 'complete') {
|
||||
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
|
||||
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, inventoryPositionId: row.inventoryPositionId, receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
|
||||
}
|
||||
pendingByScope.set(identity.value, { action, payload })
|
||||
}
|
||||
const ticket = sequence, scope = identity.value, attempt = pendingByScope.get(scope)!, customer = props.customerId, number = props.exceptionNo
|
||||
let responseSequence = ticket
|
||||
const requestKey = attempt.payload.idempotencyKey
|
||||
const sameAttempt = () => pendingByScope.get(scope)?.payload.idempotencyKey === requestKey
|
||||
inFlight.set(scope, requestKey)
|
||||
busy.value = true; error.value = ''; message.value = ''
|
||||
try {
|
||||
const result = await commandException(customer, number, attempt.action, attempt.payload)
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
|
||||
throw new Error('处理结果无法核对,请保留原请求重试')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
if (!current(ticket)) return
|
||||
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
|
||||
emit('changed'); responseSequence = sequence + 1; await load()
|
||||
} catch (cause) {
|
||||
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
|
||||
if (!current(ticket)) return
|
||||
const text = getApiErrorDetails(cause, '处理请求失败').message
|
||||
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
|
||||
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
|
||||
} finally {
|
||||
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
|
||||
if (current(responseSequence)) busy.value = false
|
||||
}
|
||||
}
|
||||
function selectHistory(fileId: string, event: Event) {
|
||||
if (locked.value) return
|
||||
selectedHistory.value = (event.target as HTMLInputElement).checked ? [...new Set([...selectedHistory.value, fileId])] : selectedHistory.value.filter(id => id !== fileId)
|
||||
}
|
||||
function removeEvidence(index: number) {
|
||||
if (locked.value) return
|
||||
evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value])
|
||||
}
|
||||
watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++sequence; busy.value = false; message.value = ''; if (props.active) void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" aria-label="异常处理详情">
|
||||
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
|
||||
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库">{{ detail.order.overseasWarehouseName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="尾程订单号">{{ detail.order.lastMileOrderNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="原入仓码">{{ detail.order.inboundCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="派送单号">{{ detail.order.deliveryNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="出库单号">{{ detail.order.outboundNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常箱数">{{ detail.order.packageCount ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SKU">{{ detail.order.skuCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="退回物流单号">{{ detail.order.returnLogisticsNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ detail.order.handlerName && !/^\d+$/.test(detail.order.handlerName) ? detail.order.handlerName : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detail.order.exceptionAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detail.order.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常原因" :span="2">{{ detail.order.reason }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理动作" :span="2">{{ detail.order.handlingAction || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理结果" :span="2">{{ detail.order.handlingResult || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history">
|
||||
<h3>退回实收与重派关联</h3>
|
||||
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
|
||||
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="!receipt.redispatches.length">尚未关联重派排车</p>
|
||||
<table v-else class="exception-redispatch-table">
|
||||
<thead><tr><th>重派排车单</th><th>排车状态</th><th>选定箱数</th><th>实际拣货</th><th>重派派送单</th></tr></thead>
|
||||
<tbody><tr v-for="dispatch in receipt.redispatches" :key="dispatch.dispatchNo">
|
||||
<td><RouterLink v-if="access.can(permission.dispatchList)" :to="`/overseas-warehouse/dispatches/${encodeURIComponent(dispatch.dispatchNo)}`">{{ dispatch.dispatchNo }}</RouterLink><span v-else>{{ dispatch.dispatchNo }}</span></td>
|
||||
<td>{{ dispatchStatusLabel(dispatch.status) }}</td><td>{{ dispatch.selectedPackages }} 箱</td><td>{{ dispatch.pickedPackages }} 箱</td>
|
||||
<td><span v-if="!dispatch.deliveries.length">—</span><div v-for="delivery in dispatch.deliveries" :key="delivery.deliveryNo">
|
||||
<RouterLink v-if="access.can(permission.deliveryList)" :to="{ path: `/overseas-warehouse/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, query: { customerId } }">{{ delivery.deliveryNo }}</RouterLink><span v-else>{{ delivery.deliveryNo }}</span>
|
||||
</div></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
|
||||
<template v-if="canProcess">
|
||||
<el-form label-position="top" :disabled="locked">
|
||||
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="可补充实际情况;留空按本次操作记录标准说明" /></el-form-item>
|
||||
<template v-if="allowed('COMPLETE_EXCEPTION')">
|
||||
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
|
||||
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
|
||||
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
|
||||
<template v-if="warehouseReturn">
|
||||
<p>只填写本批实际收到的箱数;未收齐保持处理中。目标库位取所选托盘的真实库位。</p>
|
||||
<div v-for="(row, index) in receipts" :key="`${row.outboundPalletItemId}:${row.inventoryPositionId}`">
|
||||
<p>{{ row.sourcePalletNo }} · SKU {{ row.skuCode || '—' }} · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
|
||||
<!-- Element Plus initializes number-input ARIA on mount; remount when the form lock changes. -->
|
||||
<el-form-item label="本批实收箱数"><el-input-number :key="locked ? 'locked' : 'editable'" v-model="row.quantity" :data-test="`quantity-${index}`" :min="0" :max="row.maxReturnPackages" :precision="0" placeholder="未收到可留空" /></el-form-item>
|
||||
<el-form-item label="实际收货目标托盘"><el-select :model-value="row.target" :data-test="`target-${index}`" filterable remote :remote-method="searchTargets" placeholder="输入托盘码查询" @visible-change="onTargetVisibility" @update:model-value="onTargetChange(index, $event)"><el-option v-for="target in targets" :key="target.id" :value="String(target.id)" :label="target.palletCode" /></el-select></el-form-item>
|
||||
<p>目标托盘:{{ row.palletNo || '—' }};实际库位:{{ row.locationCode || '—' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
</el-form>
|
||||
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
|
||||
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
|
||||
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exception-return-history { margin: 12px 0; }
|
||||
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
|
||||
.exception-redispatch-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.exception-redispatch-table th, .exception-redispatch-table td { padding: 6px 8px; border: 1px solid var(--el-border-color-lighter); text-align: left; }
|
||||
.exception-redispatch-table th { background: var(--el-fill-color-light); }
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import SmartDispatchDialog from './SmartDispatchDialog.vue'
|
||||
import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, 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() } }))
|
||||
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(),
|
||||
}))
|
||||
|
||||
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: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
|
||||
ElEmpty: true, ElRadio: true, ElRadioGroup: true },
|
||||
} })
|
||||
}
|
||||
const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', palletNo: 'PAL-C' }
|
||||
const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', 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' } },
|
||||
{ draftContainerId: 'container-1', dispatch: { id: '101', dispatchNo: 'DS-1' } }]
|
||||
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()
|
||||
})
|
||||
|
||||
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: '' } }]],
|
||||
])('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('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('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('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('keeps group membership frozen while the submitted batch is awaiting its response', async () => {
|
||||
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
|
||||
let resolveCommand!: (result: typeof goodResult) => void
|
||||
vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
|
||||
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
|
||||
await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click')
|
||||
await wrapper.findAll('button').find(node => node.attributes('aria-label') === '删除装车车型1')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
|
||||
expect(deleteDispatchDraftContainer).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
|
||||
resolveCommand(goodResult); await flushPromises()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retains 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.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('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: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['13'] },
|
||||
] }])
|
||||
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: ['11', '13'] },
|
||||
] }])
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
|
||||
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL' })
|
||||
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
|
||||
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11', '13'], 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' } }])
|
||||
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()
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeight'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { GripVertical, Plus } from '@lucide/vue'
|
||||
@@ -9,7 +10,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
|
||||
@@ -83,21 +84,22 @@ const orderGroups = computed(() => {
|
||||
})
|
||||
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
|
||||
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
|
||||
const unresolvedAssignedIds = computed(() => assignedIds.value.filter(id => !palletSnapshot(id)))
|
||||
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
|
||||
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
|
||||
function weightKg(item?: DispatchPalletCandidateDto) {
|
||||
const grossWeightKg = Number(item?.grossWeightKg)
|
||||
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
|
||||
const volumeWeightKg = Number(item?.volumeWeightKg)
|
||||
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
|
||||
const weightLb = (item?: DispatchPalletCandidateDto) => physicalWeightLb(item?.grossWeightKg)
|
||||
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => totalPhysicalWeightLb(pallets)
|
||||
const containerWeightLb = (container: LoadingContainer) => totalPhysicalWeightLb(container.palletIds.map(id => palletSnapshot(id)))
|
||||
const formatWeightLb = (weight: number | null) => weight === null ? '待确认' : `${number(weight, 0)}LB`
|
||||
const containerOverweight = (container: LoadingContainer) => {
|
||||
const weight = containerWeightLb(container)
|
||||
return weight !== null && weight > container.maxWeightLb * 1.1
|
||||
}
|
||||
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
|
||||
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
|
||||
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
|
||||
|
||||
function palletId(item: DispatchPalletCandidateDto) {
|
||||
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
|
||||
return String(item.planPalletId || item.warehousePalletId || '')
|
||||
}
|
||||
function text(value: unknown) {
|
||||
@@ -175,6 +177,8 @@ async function load() {
|
||||
draftToken: ensureDraftToken(),
|
||||
})
|
||||
rememberPallets(candidates.value)
|
||||
if (unresolvedAssignedIds.value.length) await hydrateDraftPallets()
|
||||
if (unresolvedAssignedIds.value.length) throw new Error('草稿中部分已选来源尚未恢复,请重试加载;原选择已保留')
|
||||
activeOrderKey.value = orderGroups.value[0]?.key || ''
|
||||
} catch (error) {
|
||||
candidates.value = []
|
||||
@@ -194,10 +198,12 @@ function reset() {
|
||||
}
|
||||
|
||||
function addContainer() {
|
||||
if (saving.value) return
|
||||
selectedVehicle.value = ''
|
||||
vehicleDialogOpen.value = true
|
||||
}
|
||||
async function confirmAddContainer() {
|
||||
if (saving.value) return
|
||||
const spec = selectedVehicleSpec.value
|
||||
if (!spec) {
|
||||
ElMessage.warning('请选择车型')
|
||||
@@ -220,6 +226,7 @@ async function confirmAddContainer() {
|
||||
|
||||
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
|
||||
const id = palletId(item)
|
||||
if (saving.value || busyPalletIds.value.includes(id)) return
|
||||
const target = containers.value.find((container) => container.id === containerId)
|
||||
if (!target || target.palletIds.includes(id)) return
|
||||
if (!String(item.overseasWarehouseId || '').trim()) {
|
||||
@@ -234,9 +241,13 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
|
||||
ElMessage.warning('该装车托盘已达到设置的托数')
|
||||
return
|
||||
}
|
||||
const nextWeight = containerWeightLb(target) + weightLb(item)
|
||||
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
|
||||
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
|
||||
const currentWeight = containerWeightLb(target), incomingWeight = weightLb(item)
|
||||
if (currentWeight === null || incomingWeight === null) ElMessage.warning('存在未确认的实际毛重,请在正式配载前补齐;材积重不能用于载重校验')
|
||||
else {
|
||||
const nextWeight = currentWeight + incomingWeight
|
||||
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
|
||||
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
|
||||
}
|
||||
const anchor = assigned.value[0]
|
||||
if (anchor?.customerId && anchor.customerId !== item.customerId) {
|
||||
ElMessage.warning('一次排车只能装同一客户的托盘')
|
||||
@@ -259,6 +270,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
|
||||
}
|
||||
|
||||
async function removePallet(id: string) {
|
||||
if (saving.value || busyPalletIds.value.includes(id)) return
|
||||
const owner = containerFor(id)
|
||||
if (!owner) return
|
||||
busyPalletIds.value.push(id)
|
||||
@@ -274,6 +286,7 @@ async function removePallet(id: string) {
|
||||
}
|
||||
|
||||
async function deleteContainer(container: LoadingContainer) {
|
||||
if (saving.value) return
|
||||
for (const id of [...container.palletIds]) {
|
||||
await removePallet(id)
|
||||
}
|
||||
@@ -292,14 +305,36 @@ function dropInto(event: DragEvent, containerId: string) {
|
||||
}
|
||||
|
||||
async function close(value = false) {
|
||||
if (saving.value) return
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (busyPalletIds.value.length) {
|
||||
ElMessage.warning('托盘来源正在锁定,请稍后再预排车')
|
||||
return
|
||||
}
|
||||
if (loading.value || unresolvedAssignedIds.value.length) {
|
||||
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
|
||||
return
|
||||
}
|
||||
if (!assignedIds.value.length) {
|
||||
ElMessage.warning('请至少移入一个托盘')
|
||||
return
|
||||
}
|
||||
const selectedContainers = containers.value.filter(container => container.palletIds.length > 0)
|
||||
if (new Set(assignedIds.value).size !== assignedIds.value.length
|
||||
|| selectedContainers.some(container => !container.id.trim())
|
||||
|| new Set(selectedContainers.map(container => container.id)).size !== selectedContainers.length) {
|
||||
ElMessage.warning('草稿中存在重复托盘或无效装车容器,请核对分组;原草稿已保留')
|
||||
return
|
||||
}
|
||||
if (assigned.value.some(item => item.sourceType === 'RETURN_RECEIPT'
|
||||
? !String(item.returnReceiptId || '').trim() : !String(item.planPalletId || '').trim())) {
|
||||
ElMessage.warning('所选托盘来源标识缺失,请重新加载核对;原草稿已保留')
|
||||
return
|
||||
}
|
||||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) {
|
||||
ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘')
|
||||
return
|
||||
@@ -314,13 +349,39 @@ async function submit() {
|
||||
ElMessage.warning('一张换标单的托盘需要单独排车')
|
||||
return
|
||||
}
|
||||
if (relabelOrders.length && selectedContainers.length !== 1) {
|
||||
ElMessage.warning('一张换标单仅支持一组排车,请将全部托盘移入同一个装车容器')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
mutationError.value = null
|
||||
try {
|
||||
await preArrangeDispatch({
|
||||
planPalletIds: assignedIds.value,
|
||||
...(relabelOrders.length ? { relabelNo: relabelOrders[0] } : { draftToken: ensureDraftToken() }),
|
||||
})
|
||||
if (relabelOrders.length) {
|
||||
await preArrangeDispatch({
|
||||
planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(palletId),
|
||||
returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
|
||||
relabelNo: relabelOrders[0],
|
||||
})
|
||||
} else {
|
||||
const groups = selectedContainers.map(container => {
|
||||
const sources = container.palletIds.map(id => palletSnapshot(id)!)
|
||||
return {
|
||||
draftContainerId: container.id,
|
||||
planPalletIds: sources.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(item => String(item.planPalletId)),
|
||||
returnReceiptIds: sources.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
|
||||
}
|
||||
})
|
||||
const results = await preArrangeDraftDispatch({ draftToken: ensureDraftToken(), groups })
|
||||
const expectedIds = new Set(groups.map(group => group.draftContainerId))
|
||||
if (!Array.isArray(results) || results.length !== groups.length
|
||||
|| results.some(result => !result || typeof result.draftContainerId !== 'string'
|
||||
|| typeof result.dispatch?.dispatchNo !== 'string' || !result.dispatch.dispatchNo.trim())
|
||||
|| new Set(results.map(result => result.draftContainerId)).size !== groups.length
|
||||
|| results.some(result => !expectedIds.has(result.draftContainerId))
|
||||
|| new Set(results.map(result => result.dispatch.dispatchNo)).size !== groups.length) {
|
||||
throw new Error('预排车返回的分组结果与原草稿不一致,原草稿已保留,请核对保存结果')
|
||||
}
|
||||
}
|
||||
emit('update:modelValue', false)
|
||||
emit('created')
|
||||
ElMessage.success('排车明细已同步到预排列表')
|
||||
@@ -360,7 +421,6 @@ watch(
|
||||
palletIds: c.palletIds || [],
|
||||
}
|
||||
})
|
||||
await hydrateDraftPallets().catch(() => undefined)
|
||||
}
|
||||
await load()
|
||||
})()
|
||||
@@ -434,7 +494,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="重量(lb)" width="100" align="right"
|
||||
><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
|
||||
><template #default="{ row }">{{ formatWeightLb(orderWeightLb(row.pallets)) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="托盘数量" width="90" align="right"
|
||||
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
|
||||
@@ -479,7 +539,8 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
<GripVertical :size="16" class="drag-handle" />
|
||||
<div class="pallet-copy">
|
||||
<strong>{{ text(item.palletNo) }}</strong
|
||||
><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span>
|
||||
><span>{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}</span>
|
||||
<span v-if="item.sourceType === 'RETURN_RECEIPT'">退回实收:{{ text(item.returnReceiptNo) }}</span>
|
||||
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
|
||||
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
|
||||
<span>{{ mark }}</span
|
||||
@@ -518,7 +579,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
:key="container.id"
|
||||
class="loading-container"
|
||||
:class="{
|
||||
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
|
||||
'loading-container--overweight': containerOverweight(container),
|
||||
}"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropInto($event, container.id)"
|
||||
@@ -527,15 +588,15 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
<strong>{{ container.vehicleLabel }}</strong
|
||||
><span
|
||||
>托数 <b>{{ container.palletIds.length }}</b
|
||||
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
|
||||
><el-button class="density-action" data-tone="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
|
||||
>/{{ container.capacity }} · 重量 <b>{{ formatWeightLb(containerWeightLb(container)) }}</b>/{{ container.maxWeightLb }}LB <em v-if="containerOverweight(container)">超限10%</em></span
|
||||
><el-button class="density-action" data-tone="danger" :disabled="saving" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
|
||||
</div>
|
||||
<div class="container-body">
|
||||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
|
||||
<span
|
||||
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
|
||||
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
|
||||
><el-button class="density-action" data-tone="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
|
||||
><el-button class="density-action" data-tone="danger" :disabled="saving" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
|
||||
</div>
|
||||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
|
||||
</div>
|
||||
@@ -547,7 +608,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
|
||||
<template #footer><footer class="dialog-footer">
|
||||
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
|
||||
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div>
|
||||
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
|
||||
</footer></template>
|
||||
</AppDialog>
|
||||
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WarehouseTransferFulfillmentPanel from './WarehouseTransferFulfillmentPanel.vue'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { listWarehouseTransferSources } from '../api/warehouseTransferSources'
|
||||
|
||||
const { push, can } = vi.hoisted(() => ({ push: vi.fn(), can: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), allocateWarehouseTransfer: vi.fn() }))
|
||||
vi.mock('../api/warehouseTransferSources', () => ({ listWarehouseTransferSources: vi.fn() }))
|
||||
const dialogStub = defineComponent({ props: { modelValue: Boolean, confirmDisabled: Boolean, confirmText: String }, emits: ['confirm', 'update:modelValue'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', { class: 'allocation-dialog' }, [slots.default?.(), h('button', { class: 'confirm-allocation', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, props.confirmText)]) : null } })
|
||||
enableAutoUnmount(afterEach)
|
||||
function fixture(transferNo = 'WT-1', values: Partial<WarehouseTransferDetailDto> = {}): WarehouseTransferDetailDto {
|
||||
return { id: 'internal-transfer-id', transferNo, packageCount: 2, needRelabel: true, status: 'APPROVED', items: [], cartons: [], pallets: [], relabelBindings: [], ...values }
|
||||
}
|
||||
function render(customerId = 'customer-1', transferNo = 'WT-1') {
|
||||
return mount(WarehouseTransferFulfillmentPanel, { props: { customerId, transferNo }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub } } })
|
||||
}
|
||||
describe('WarehouseTransferFulfillmentPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture()) })
|
||||
it('requires manage and inventory read permissions before offering allocation', async () => {
|
||||
can.mockImplementation(code => code !== 'lastleg:warehouse-transfer:manage')
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('选择库存来源 / 分配')
|
||||
expect(listWarehouseTransferSources).not.toHaveBeenCalled()
|
||||
})
|
||||
it('confirms exact quantities and preserves original body/key through unknown failure, close and reopen', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ issues: [], candidates: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }, { inventoryPositionId: 'HOLD', palletId: 'PALLET-HOLD', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-HOLD', warehouseLocationCode: 'LOC-1', availablePackages: 0, canSelect: false, blockReason: '当前无可分配箱数' }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-NEW', relabelStatus: 'PENDING_REVIEW' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
const available = wrapper.find('.allocation-dialog').get('label[aria-label="选择 DP-1 OLD"] input')
|
||||
const held = wrapper.find('.allocation-dialog').get('label[aria-label="选择 DP-HOLD OLD"] input')
|
||||
expect(held.attributes('disabled')).toBeDefined()
|
||||
await available.setValue(true); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeUndefined()
|
||||
const quantity = wrapper.get('input[aria-label="DP-1 分配箱数"]')
|
||||
expect(quantity.attributes('max')).toBe('3')
|
||||
await quantity.setValue('2'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
await quantity.setValue('1'); await flushPromises()
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const [originalCustomer, originalTransferNo, originalPayload] = vi.mocked(allocateWarehouseTransfer).mock.calls[0]!
|
||||
const original = [originalCustomer, originalTransferNo, { idempotencyKey: originalPayload.idempotencyKey, allocations: originalPayload.allocations.map(row => ({ ...row })) }]
|
||||
expect(original).toEqual(['customer-1', 'WT-1', { idempotencyKey: expect.any(String), allocations: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', packageCount: 1 }] }])
|
||||
expect(wrapper.text()).toContain('已保留本次选择,重试使用相同内容')
|
||||
expect(wrapper.text()).not.toContain('幂等键')
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '重试本次库存分配')!.trigger('click'); await flushPromises()
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(wrapper.text()).toContain('待换标'); expect(wrapper.text()).toContain('RL-NEW')
|
||||
})
|
||||
it('keeps ambiguous sources and source query errors explicit and cannot submit them', async () => {
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValueOnce({ candidates: [], issues: ['原票库存代表不匹配,待核查'] }).mockRejectedValueOnce(new Error('query lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待核查'); expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源库存加载失败,请重试')
|
||||
expect(allocateWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('recovers a definite inventory conflict through an explicit fresh choice and submits a new command', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
const row = { inventoryPositionId: 'OLD-POSITION', palletId: 'OLD-PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-OLD', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValueOnce({ issues: [], candidates: [row] }).mockResolvedValueOnce({ issues: [], candidates: [{ ...row, inventoryPositionId: 'NEW-POSITION', palletId: 'NEW-PALLET', palletNo: 'DP-NEW', availablePackages: 1 }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(new ApiResponseError('库存可用箱数已变化', 'A0409', 200, undefined, 'conflict-request', 'conflict-trace')).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-RECOVERED' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('label[aria-label="选择 DP-OLD OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const oldPayload = vi.mocked(allocateWarehouseTransfer).mock.calls[0]![2]
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('重新选择来源')
|
||||
wrapper.getComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '查看分配问题')!.trigger('click'); await flushPromises()
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.findAll('button').find(x => x.text() === '重新选择来源')!.trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(2)
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.get('[aria-label="上次分配诊断"]').text()).toContain('库存可用箱数已变化')
|
||||
expect(wrapper.get('[aria-label="上次分配诊断"]').text()).toContain('conflict-request')
|
||||
expect(wrapper.find('label[aria-label="选择 DP-OLD OLD"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.confirm-allocation').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('label[aria-label="选择 DP-NEW OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const newPayload = vi.mocked(allocateWarehouseTransfer).mock.calls[1]![2]
|
||||
expect(newPayload.idempotencyKey).not.toBe(oldPayload.idempotencyKey)
|
||||
expect(newPayload.allocations).toEqual([{ inventoryPositionId: 'NEW-POSITION', palletId: 'NEW-PALLET', packageCount: 1 }])
|
||||
expect(oldPayload.allocations).toEqual([{ inventoryPositionId: 'OLD-POSITION', palletId: 'OLD-PALLET', packageCount: 1 }])
|
||||
expect(wrapper.text()).toContain('RL-RECOVERED')
|
||||
expect(wrapper.text()).not.toContain('幂等键')
|
||||
})
|
||||
it.each([
|
||||
new ApiResponseError('服务响应异常', 'A0409', 503),
|
||||
new ApiResponseError('身份响应异常', 'A0409', 401),
|
||||
new ApiResponseError('分配响应身份不一致', 'TRANSFER_IDENTITY_MISMATCH', 200),
|
||||
])('keeps uncertain %s outcomes locked to the original command', async cause => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { packageCount: 1 }))
|
||||
vi.mocked(listWarehouseTransferSources).mockResolvedValue({ issues: [], candidates: [{ inventoryPositionId: 'POSITION', palletId: 'PALLET', inboundCode: 'IN-1', skuCode: 'OLD', productNameZh: '货物', palletNo: 'DP-1', warehouseLocationCode: 'LOC-1', availablePackages: 3, canSelect: true, blockReason: '' }] })
|
||||
vi.mocked(allocateWarehouseTransfer).mockRejectedValueOnce(cause).mockResolvedValueOnce(fixture('WT-1', { status: 'WAIT_RELABEL', relabelNo: 'RL-RETRIED' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(x => x.text() === '选择库存来源 / 分配')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('label[aria-label="选择 DP-1 OLD"] input').setValue(true); await flushPromises()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(allocateWarehouseTransfer).mock.calls[0]![2]
|
||||
expect(wrapper.text()).not.toContain('重新选择来源')
|
||||
expect(wrapper.get('label[aria-label="选择 DP-1 OLD"] input').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('.confirm-allocation').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(allocateWarehouseTransfer).mock.calls[1]![2]).toEqual(original)
|
||||
expect(listWarehouseTransferSources).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('reads only the exact source once and blocks missing relabel prerequisites', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-1')
|
||||
expect(wrapper.text()).toContain('尚未关联换标单')
|
||||
expect(wrapper.text()).toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('internal-transfer-id')
|
||||
expect(wrapper.findAll('button').some(x => /出库|跳过|确认换标完成|分配库存/.test(x.text()))).toBe(false)
|
||||
})
|
||||
it('links the business relabel number and keeps customer confirmation pending', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { status: 'WAIT_RELABEL', relabelApplicationId: 'internal-relabel-id', relabelNo: 'RL-1', relabelStatus: 'CUSTOMER_CONFIRM_PENDING' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待客户确认'); expect(wrapper.text()).toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('internal-relabel-id')
|
||||
await wrapper.findAll('button').find(x => x.text() === '查看本票换标单')!.trigger('click')
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-relabel-order-detail', params: { orderId: 'RL-1' } })
|
||||
})
|
||||
it('requires relabel read permission and preserves later inventory gates', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { relabelNo: 'RL-1', relabelStatus: 'READY_FOR_OUTBOUND' }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('后续出库仍须'); expect(wrapper.text()).not.toContain('查看本票换标单')
|
||||
})
|
||||
it('never displays MIXED or internal allocation identities as actual cargo', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { allocations: [{ inventoryPositionId: 'internal-position', inboundOrderId: 'internal-inbound', inboundCode: 'IN-1', palletId: 'internal-pallet', palletNo: 'PALLET-1', packageCount: 2, status: 'RESERVED', skuCode: 'MIXED' }] }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('PALLET-1')
|
||||
for (const value of ['MIXED', 'internal-position', 'internal-inbound', 'internal-pallet']) expect(wrapper.text()).not.toContain(value)
|
||||
})
|
||||
it('does not fall back from missing source number to an internal or tail order ID', async () => {
|
||||
const wrapper = render('customer-1', ''); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少客户或来源转运单号')
|
||||
})
|
||||
it('shows a real empty allocation state for transfers that do not need relabel', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(fixture('WT-1', { needRelabel: false, status: 'ALLOCATED', allocations: [] }))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未确认本票库存来源分配')
|
||||
expect(wrapper.text()).not.toContain('不得提前出库')
|
||||
expect(wrapper.text()).not.toContain('查看本票换标单')
|
||||
})
|
||||
it('does not query across customer scope when the customer is missing', async () => {
|
||||
const wrapper = render('', 'WT-1'); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少客户或来源转运单号')
|
||||
})
|
||||
it('does not display a response that arrives after the page is closed', async () => {
|
||||
let resolve!: (value: WarehouseTransferDetailDto) => void
|
||||
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = render(); wrapper.unmount()
|
||||
resolve(fixture('WT-1', { relabelNo: 'RL-LATE' })); await flushPromises()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('ignores old source responses and rejects a mismatched transfer identity', async () => {
|
||||
let resolveFirst!: (value: WarehouseTransferDetailDto) => void
|
||||
vi.mocked(getWarehouseTransfer).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })).mockResolvedValueOnce(fixture('WT-2'))
|
||||
const wrapper = render(); await wrapper.setProps({ transferNo: 'WT-2' }); await flushPromises()
|
||||
resolveFirst(fixture('WT-1', { relabelNo: 'RL-OLD' })); await flushPromises()
|
||||
expect(wrapper.text()).toContain('WT-2'); expect(wrapper.text()).not.toContain('RL-OLD')
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(fixture('WT-WRONG'))
|
||||
await wrapper.findAll('button').find(x => x.text() === '刷新转运状态')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('返回的转运单与当前票不一致'); expect(wrapper.text()).not.toContain('WT-WRONG')
|
||||
})
|
||||
it('clears stale data on read failure and retries explicitly', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
vi.mocked(getWarehouseTransfer).mockRejectedValueOnce(new Error('测试读取失败'))
|
||||
await wrapper.findAll('button').find(x => x.text() === '刷新转运状态')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('转运履约加载失败,请重试'); expect(wrapper.text()).not.toContain('尚未关联换标单')
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValueOnce(fixture('WT-1', { relabelNo: 'RL-RETRY' }))
|
||||
await wrapper.findAll('button').find(x => x.text().includes('重新加载'))!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('RL-RETRY')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { allocateWarehouseTransfer, getWarehouseTransfer, type AllocateWarehouseTransferPayload, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
import { listWarehouseTransferSources, type WarehouseTransferSourceCandidate } from '../api/warehouseTransferSources'
|
||||
import { lastMileStatusLabel, relabelApplicationStatusLabel } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue'
|
||||
|
||||
const props = defineProps<{ customerId: string; transferNo: string }>()
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
let sourceRequest = 0
|
||||
const allocationOpen = ref(false), sourceLoading = ref(false), allocationSaving = ref(false)
|
||||
const sourceError = ref<ApiErrorDetails | null>(null), allocationError = ref<ApiErrorDetails | null>(null)
|
||||
const sourceRows = ref<WarehouseTransferSourceCandidate[]>([]), sourceIssues = ref<string[]>([])
|
||||
const selected = ref<string[]>([]), quantities = ref<Record<string, number>>({})
|
||||
const originalCommand = ref<AllocateWarehouseTransferPayload | null>(null)
|
||||
const allocationRejected = ref(false), previousRejection = ref<ApiErrorDetails | null>(null)
|
||||
function isDefiniteAllocationRejection(cause: unknown) {
|
||||
const rejectionCodes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||||
const knownStatus = (status: number | undefined) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||||
if (cause instanceof ApiResponseError) return rejectionCodes.includes(cause.code) && knownStatus(cause.status)
|
||||
if (axios.isAxiosError<{ code?: string }>(cause)) return !!cause.response && knownStatus(cause.response.status) && rejectionCodes.includes(cause.response.data?.code || '')
|
||||
return false
|
||||
}
|
||||
const canAllocate = computed(() => access.can('lastleg:warehouse-transfer:manage') && access.can(permission.inventoryList) && (detail.value?.status === 'APPROVED' || originalCommand.value !== null))
|
||||
const selectedPackages = computed(() => selected.value.reduce((sum, id) => sum + (quantities.value[id] || 0), 0))
|
||||
const selectionValid = computed(() => !!detail.value && !sourceIssues.value.length && selected.value.length > 0 && selectedPackages.value === detail.value.packageCount && selected.value.every(id => {
|
||||
const row = sourceRows.value.find(item => item.inventoryPositionId === id), count = quantities.value[id] || 0
|
||||
return row?.canSelect && Number.isSafeInteger(count) && count > 0 && count <= row.availablePackages
|
||||
}))
|
||||
const sourceColumns: DataTableColumn[] = [
|
||||
{ key: 'selection', label: '选择', width: 70, fixed: 'left', slotName: 'sourceSelection' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘码', minWidth: 160 },
|
||||
{ key: 'warehouseLocationCode', prop: 'warehouseLocationCode', label: '库位', minWidth: 110 },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: '实际旧 SKU', minWidth: 130 },
|
||||
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 130 },
|
||||
{ key: 'availablePackages', prop: 'availablePackages', label: '当前可分配', width: 100 },
|
||||
{ key: 'quantity', label: '本次箱数', width: 130, slotName: 'sourceQuantity' },
|
||||
{ key: 'blockReason', prop: 'blockReason', label: '不可选择原因', minWidth: 200 },
|
||||
]
|
||||
async function openAllocation() {
|
||||
if (!canAllocate.value) return
|
||||
allocationOpen.value = true
|
||||
if (!originalCommand.value) await loadSources()
|
||||
}
|
||||
async function loadSources() {
|
||||
if (originalCommand.value) return
|
||||
if (!detail.value) {
|
||||
await load()
|
||||
if (error.value) { sourceError.value = error.value; return }
|
||||
}
|
||||
if (!detail.value || !canAllocate.value) return
|
||||
const current = ++sourceRequest
|
||||
sourceLoading.value = true; sourceError.value = null; allocationError.value = null
|
||||
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
try {
|
||||
const result = await listWarehouseTransferSources(props.customerId, detail.value)
|
||||
if (current === sourceRequest) { sourceRows.value = result.candidates; sourceIssues.value = result.issues }
|
||||
} catch (cause) { if (current === sourceRequest) sourceError.value = getApiErrorDetails(cause, '来源库存加载失败,请重试') }
|
||||
finally { if (current === sourceRequest) sourceLoading.value = false }
|
||||
}
|
||||
function selectSource(id: string, value: boolean) {
|
||||
const row = sourceRows.value.find(candidate => candidate.inventoryPositionId === id)
|
||||
if (!row?.canSelect || originalCommand.value) return
|
||||
selected.value = value ? [...new Set([...selected.value, row.inventoryPositionId])] : selected.value.filter(id => id !== row.inventoryPositionId)
|
||||
if (value && !quantities.value[row.inventoryPositionId]) quantities.value[row.inventoryPositionId] = 1
|
||||
}
|
||||
async function confirmAllocation() {
|
||||
if (allocationSaving.value || allocationRejected.value || !canAllocate.value || (!originalCommand.value && !selectionValid.value)) return
|
||||
if (!originalCommand.value) originalCommand.value = { idempotencyKey: crypto.randomUUID(), allocations: selected.value.map(id => ({ inventoryPositionId: id, palletId: sourceRows.value.find(row => row.inventoryPositionId === id)!.palletId, packageCount: quantities.value[id]! })) }
|
||||
const customerId = props.customerId, transferNo = props.transferNo, command = originalCommand.value
|
||||
allocationSaving.value = true; allocationError.value = null
|
||||
try {
|
||||
const value = await allocateWarehouseTransfer(customerId, transferNo, command)
|
||||
if (customerId !== props.customerId || transferNo !== props.transferNo) return
|
||||
if (value.transferNo !== transferNo) throw new ApiResponseError('分配响应与当前转运单不一致,请使用原操作核查。', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
detail.value = value; originalCommand.value = null; allocationOpen.value = false
|
||||
previousRejection.value = null
|
||||
} catch (cause) {
|
||||
if (customerId !== props.customerId || transferNo !== props.transferNo) return
|
||||
allocationRejected.value = isDefiniteAllocationRejection(cause)
|
||||
allocationError.value = getApiErrorDetails(cause, '库存分配结果尚未确认,已保留本次选择,重试使用相同内容')
|
||||
}
|
||||
finally { allocationSaving.value = false }
|
||||
}
|
||||
async function reselectSources() {
|
||||
if (!allocationRejected.value || allocationSaving.value || !canAllocate.value) return
|
||||
previousRejection.value = allocationError.value
|
||||
allocationRejected.value = false; originalCommand.value = null; allocationError.value = null
|
||||
sourceRequest++; sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
await load()
|
||||
if (error.value) { sourceError.value = error.value; return }
|
||||
if (detail.value?.status !== 'APPROVED') {
|
||||
sourceError.value = { message: '当前转运状态已变化,请核对最新履约后再分配库存。' }
|
||||
return
|
||||
}
|
||||
await loadSources()
|
||||
}
|
||||
const fields = computed(() => detail.value ? [
|
||||
['转运单号', detail.value.transferNo], ['转运状态', lastMileStatusLabel(detail.value.status)],
|
||||
['需要换标', detail.value.needRelabel ? '是' : '否'], ['关联换标单', detail.value.relabelNo || '未关联'],
|
||||
['换标状态', detail.value.relabelStatus ? relabelApplicationStatusLabel(detail.value.relabelStatus) : '—'],
|
||||
] : [])
|
||||
const gateText = computed(() => {
|
||||
if (!detail.value?.needRelabel) return ''
|
||||
if (!detail.value.relabelNo) return '尚未关联换标单。须先确认本票库存来源并分配,再按关联换标单完成 PDA 作业、复核和客户确认;不得提前出库。'
|
||||
if (!['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.value.relabelStatus || '')) return '本票换标尚未完成复核和客户确认,不得提前出库。请沿关联换标单查看 PDA 作业及客户确认进度。'
|
||||
return '换标已完成。后续出库仍须按转运单与服务端库存门禁执行。'
|
||||
})
|
||||
const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—', movementStatus: ({ RESERVED: '已分配', SHIPPED: '已发运', RECEIVED: '目标已实收' } as Record<string, string>)[row.status] || '—' })))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '来源入仓码', minWidth: 160 },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '来源托盘码', minWidth: 180 },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: '实际 SKU', minWidth: 150 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '分配箱数', width: 100 },
|
||||
{ key: 'movementStatus', prop: 'movementStatus', label: '履约状态', width: 120 },
|
||||
]
|
||||
async function load() {
|
||||
const current = ++request
|
||||
detail.value = null; error.value = null; loading.value = false
|
||||
if (!props.customerId || !props.transferNo) { error.value = { message: '本票缺少客户或来源转运单号,无法读取转运履约。' }; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const value = await getWarehouseTransfer(props.customerId, props.transferNo)
|
||||
if (current !== request) return
|
||||
if (value.transferNo !== props.transferNo) throw new ApiResponseError('返回的转运单与当前票不一致,请重新加载。', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
detail.value = value
|
||||
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '转运履约加载失败,请重试') }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
function openRelabel() {
|
||||
if (detail.value?.relabelNo && access.can(permission.relabelOrderList)) void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: detail.value.relabelNo } })
|
||||
}
|
||||
watch(() => [props.customerId, props.transferNo], () => {
|
||||
sourceRequest++; allocationOpen.value = false; originalCommand.value = null
|
||||
sourceRows.value = []; sourceIssues.value = []; selected.value = []; quantities.value = {}
|
||||
sourceError.value = null; allocationError.value = null
|
||||
allocationRejected.value = false; previousRejection.value = null
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { request++; sourceRequest++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="transfer-fulfillment" aria-label="本票转运与换标履约" :aria-busy="loading">
|
||||
<div class="transfer-fulfillment-heading"><h2>转运与换标履约</h2><div><el-button v-if="canAllocate" @click="openAllocation">{{ allocationRejected ? '查看分配问题' : originalCommand ? '重试本次库存分配' : '选择库存来源 / 分配' }}</el-button><el-button :loading="loading" :disabled="allocationSaving" @click="load">刷新转运状态</el-button></div></div>
|
||||
<LoadErrorState v-if="error" compact title="转运履约加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<dl class="density-fields"><div v-for="[label, value] in fields" :key="label"><dt>{{ label }}</dt><dd>{{ value }}</dd></div></dl>
|
||||
<p v-if="gateText" role="status">{{ gateText }}</p>
|
||||
<el-button v-if="detail.relabelNo && access.can(permission.relabelOrderList)" @click="openRelabel">查看本票换标单</el-button>
|
||||
<DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" />
|
||||
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
|
||||
</template>
|
||||
</section>
|
||||
<AppDialog v-model="allocationOpen" class="admin-density-dialog" title="选择本票库存来源" width="min(1320px, calc(100vw - 40px))" :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
|
||||
<p>{{ transferNo }} · 本票 {{ detail?.packageCount }} 箱 · 已选择 {{ selectedPackages }} 箱</p>
|
||||
<p v-if="originalCommand && !allocationRejected" role="status">分配结果尚未确认,已保留本次选择,重试使用相同内容。</p>
|
||||
<p v-if="allocationRejected" role="status">本次分配未成功,请重新选择来源并核对当前可分配箱数后再次提交。</p>
|
||||
<section v-if="previousRejection" aria-label="上次分配诊断"><p>上次分配未成功:{{ previousRejection.message }}</p><ApiErrorMeta :details="previousRejection" /></section>
|
||||
<LoadErrorState v-if="sourceError" compact title="来源库存加载失败" :message="sourceError.message" @retry="loadSources"><template #details><ApiErrorMeta :details="sourceError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
|
||||
<p v-for="issue in sourceIssues" :key="issue" role="status">{{ issue }}</p>
|
||||
<DataTable v-if="!sourceError" :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
|
||||
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
|
||||
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-fulfillment { padding-bottom: var(--admin-space-5); }
|
||||
.transfer-fulfillment-heading { display: flex; align-items: center; justify-content: space-between; }
|
||||
.transfer-fulfillment-heading h2 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.transfer-fulfillment p { color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue'
|
||||
import { getWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||||
import { getPallet, listPallets } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
const { can } = vi.hoisted(() => ({ can: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), outboundWarehouseTransfer: vi.fn(), receiveWarehouseTransfer: vi.fn(), listTransferReceivingWarehouses: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
|
||||
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const detail = (status = 'ALLOCATED'): WarehouseTransferDetailDto => ({ id: 'hidden', transferNo: 'WT-1', packageCount: 1, status, needRelabel: false, fbaWarehouseCode: 'ABE4', destinationWarehouseCode: 'ABE4', items: [], cartons: [], pallets: [], relabelBindings: [], receiptCommands: [], allocations: [{ inventoryPositionId: 'hidden-source', inboundOrderId: 'hidden-inbound', inboundCode: 'IN-1', palletId: 'hidden-la', palletNo: 'LA-CODE', skuCode: 'NEW', packageCount: 1, status: status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED' }] })
|
||||
const render = (value = detail()) => mount(WarehouseTransferMovementPanel, { props: { customerId: 'customer', detail: value }, global: { plugins: [ElementPlus] } })
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(x => x.text() === label)!
|
||||
describe('transfer movement UI', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) })
|
||||
it('uses the overseas resource warehouse permission and does not require warehouse master access', async () => {
|
||||
can.mockImplementation(code => code !== 'platform:warehouse:list')
|
||||
const permitted = render(detail('OUTBOUNDED')); expect(permitted.text()).toContain('登记目标仓实收')
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:warehouse:list')
|
||||
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: false, canReselect: true },
|
||||
{ cause: new Error('response lost'), recorded: false, canReselect: false },
|
||||
{ cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: true, canReselect: false },
|
||||
])('allows target correction only after definite rejection plus authoritative no-command readback: $canReselect', async ({ cause, recorded, canReselect }) => {
|
||||
const body = { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'original-key' }
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: { type: 'receipt', body } }))
|
||||
const fresh = detail('OUTBOUNDED')
|
||||
if (recorded) fresh.receiptCommands = [{ sourceInventoryPositionId: 'hidden-source', status: 'PREPARED', requestHash: 'hash', request: body, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValue(cause); vi.mocked(getWarehouseTransfer).mockResolvedValue(fresh)
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('重新选择目标')
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text().includes('重新选择目标')).toBe(canReselect)
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending.body).toEqual(body)
|
||||
if (canReselect) {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] })
|
||||
await button(wrapper, '重新选择目标').trigger('click'); await flushPromises()
|
||||
expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending).toBeNull()
|
||||
expect(wrapper.text()).toContain('上次目标选择被拒绝:目标不可用')
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
it('recovers a reserved original server command in a fresh browser and never queries arbitrary occupied resources', async () => {
|
||||
const command: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'hidden-source', status: 'RESERVED', requestHash: 'server-hash', request: { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'server-original' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
|
||||
const d = detail('OUTBOUNDED'); d.receiptCommands = [command]
|
||||
const completed = detail('RECEIVED'); completed.allocations![0]!.status = 'RECEIVED'; completed.receiptCommands = [{ ...command, status: 'COMPLETED' }]
|
||||
vi.mocked(receiveWarehouseTransfer).mockResolvedValue(completed)
|
||||
const wrapper = render(d); await flushPromises()
|
||||
expect(wrapper.text()).toContain('从本票服务端记录恢复'); expect(wrapper.text()).not.toContain('登记目标仓实收')
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer', 'WT-1', command.request)
|
||||
expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'RECEIVED' })
|
||||
expect(listPallets).not.toHaveBeenCalled(); expect(getPallet).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not use a browser remembered occupied target when server receipt history is missing', async () => {
|
||||
sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: null, previous: { warehouse: { id: 'OTHER' }, pallet: { id: 'OTHER', status: 'OCCUPIED' } } }))
|
||||
const d = detail('PARTIALLY_RECEIVED'); delete d.receiptCommands
|
||||
const wrapper = render(d); expect(wrapper.text()).toContain('尚未返回服务端实收操作记录'); expect(wrapper.text()).not.toContain('登记目标仓实收'); expect(getPallet).not.toHaveBeenCalled()
|
||||
})
|
||||
it('permits same occupied target from completed server history in a fresh browser', async () => {
|
||||
const d = detail('PARTIALLY_RECEIVED')
|
||||
const completed: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'already-received', status: 'COMPLETED', requestHash: 'hash', request: { sourceInventoryPositionId: 'already-received', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'first-key' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }
|
||||
d.receiptCommands = [completed]
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
vi.mocked(getPallet).mockResolvedValue({ id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'OCCUPIED', version: 1 })
|
||||
vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'OCCUPIED', version: 1 })
|
||||
const wrapper = render(d); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('目标:NY-CODE / PALLET-NY / LOC-NY')
|
||||
expect(getPallet).toHaveBeenCalledExactlyOnceWith('NY', 'P-NY')
|
||||
expect(listPallets).not.toHaveBeenCalled()
|
||||
await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await flushPromises()
|
||||
expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined()
|
||||
expect(receiveWarehouseTransfer).not.toHaveBeenCalled()
|
||||
})
|
||||
it('requires permission and explicit actual-outbound confirmation; unknown outcome survives remount with same body', async () => {
|
||||
vi.mocked(outboundWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('OUTBOUNDED'))
|
||||
const first = render(); await button(first, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await button(first, '确认实际出库').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(outboundWarehouseTransfer).mock.calls[0]))
|
||||
expect(first.text()).toContain('原操作内容已保留'); first.unmount()
|
||||
const second = render(detail('OUTBOUNDED')); await flushPromises()
|
||||
expect(second.text()).not.toContain('hidden-source'); expect(second.text()).toContain('重试原操作')
|
||||
await button(second, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(second.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' })
|
||||
can.mockReturnValue(false); const denied = render(); expect(denied.text()).not.toContain('确认本票出库')
|
||||
})
|
||||
it('queries only selected warehouse page, reads exact bound location and keeps receipt request after response loss', async () => {
|
||||
vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] })
|
||||
const p = { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }
|
||||
vi.mocked(listPallets).mockResolvedValue({ records: [p], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPallet).mockResolvedValue(p)
|
||||
vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'IDLE', version: 0 })
|
||||
vi.mocked(receiveWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('RECEIVED'))
|
||||
const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises()
|
||||
expect(listPallets).not.toHaveBeenCalled()
|
||||
await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'NY')
|
||||
await wrapper.get('input[aria-label="目标托盘码查询"]').setValue('PALLET-NY'); await button(wrapper, '查询目标托盘').trigger('click'); await flushPromises()
|
||||
expect(listPallets).toHaveBeenCalledExactlyOnceWith('NY', { page: 1, size: 20, keyword: 'PALLET-NY', status: 'IDLE' })
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[2]!.vm.$emit('update:modelValue', 'P-NY'); await flushPromises()
|
||||
expect(getOverseasLocation).toHaveBeenCalledExactlyOnceWith('NY', 'L-NY')
|
||||
await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(receiveWarehouseTransfer).mock.calls[0]))
|
||||
expect(original[2]).toMatchObject({ sourceInventoryPositionId: 'hidden-source', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY' })
|
||||
await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises()
|
||||
expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]).toEqual(original)
|
||||
expect(wrapper.text()).not.toContain('hidden-source')
|
||||
})
|
||||
it('does not request resources when permission is absent or source already received', async () => {
|
||||
can.mockImplementation(code => code !== 'platform:overseas-warehouse:location:list')
|
||||
const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled()
|
||||
can.mockReturnValue(true); const done = detail('RECEIVED'); done.allocations![0]!.status = 'RECEIVED'; const wrapper = render(done); expect(wrapper.text()).not.toContain('登记目标仓实收')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { outboundWarehouseTransfer, receiveWarehouseTransfer, getWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type OutboundWarehouseTransferPayload, type ReceiveWarehouseTransferPayload, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||||
import { getPallet, listPallets, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
|
||||
|
||||
const props = defineProps<{ customerId: string; detail: WarehouseTransferDetailDto }>()
|
||||
const emit = defineEmits<{ updated: [detail: WarehouseTransferDetailDto] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
type Pending = { type: 'outbound'; body: OutboundWarehouseTransferPayload } | { type: 'receipt'; body: ReceiveWarehouseTransferPayload }
|
||||
const pending = ref<Pending | null>(null)
|
||||
const definiteRejection = ref(false), reselectAllowed = ref(false), rejectionEvidence = ref<ApiErrorDetails | null>(null)
|
||||
function definiteBusinessRejection(cause: unknown) {
|
||||
const codes = ['A0400', 'A0404', 'A0409', 'A0422']
|
||||
const statusKnown = (status?: number) => status === undefined || [200, 400, 404, 409, 422].includes(status)
|
||||
if (cause instanceof ApiResponseError) return codes.includes(cause.code) && statusKnown(cause.status)
|
||||
return axios.isAxiosError<{ code?: string }>(cause) && !!cause.response && statusKnown(cause.response.status) && codes.includes(cause.response.data?.code || '')
|
||||
}
|
||||
const completedTargets = computed(() => props.detail.receiptCommands?.filter(c => c.status === 'COMPLETED').map(c => c.target).filter((t, i, all) => all.findIndex(v => v.warehouseId === t.warehouseId && v.palletId === t.palletId && v.locationId === t.locationId) === i) ?? [])
|
||||
const previous = computed(() => completedTargets.value[0] ?? null)
|
||||
function acknowledgedTarget(value: ReceiptTarget) { return completedTargets.value.find(t => t.warehouseId === value.warehouse.id && t.palletId === value.pallet.id && t.locationId === value.location.id) ?? null }
|
||||
const unfinished = computed(() => props.detail.receiptCommands?.filter(c => c.status !== 'COMPLETED') ?? [])
|
||||
const busy = ref(false), error = ref<ApiErrorDetails | null>(null), notice = ref(''), open = ref(false)
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]), warehouseId = ref(''), keyword = ref(''), page = ref(1), total = ref(0)
|
||||
const pallets = ref<PalletDto[]>([]), target = ref<ReceiptTarget | null>(null), sourceId = ref('')
|
||||
let generation = 0
|
||||
const canManage = computed(() => access.can('lastleg:warehouse-transfer:manage'))
|
||||
const canResources = computed(() => canManage.value && access.can('platform:overseas-warehouse:warehouse:list') && access.can('platform:overseas-warehouse:pallet:list') && access.can('platform:overseas-warehouse:location:list'))
|
||||
const shipped = computed(() => (props.detail.allocations || []).filter(a => a.status === 'SHIPPED'))
|
||||
const outboundReady = computed(() => canManage.value && canOutboundTransfer(props.detail))
|
||||
const receiptReady = computed(() => canResources.value && Array.isArray(props.detail.receiptCommands) && ['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(props.detail.status) && shipped.value.length > 0 && !unfinished.value.length)
|
||||
const selectedValid = computed(() => target.value && shipped.value.some(a => a.inventoryPositionId === sourceId.value) && receiptTargetValid(props.detail, target.value, acknowledgedTarget(target.value)))
|
||||
const storageKey = () => `warehouse-transfer-movement:${props.customerId}:${props.detail.transferNo}`
|
||||
function persist() { sessionStorage.setItem(storageKey(), JSON.stringify({ pending: pending.value, definiteRejection: definiteRejection.value })) }
|
||||
function restore() {
|
||||
generation++; busy.value = false; pending.value = null; error.value = null; notice.value = ''; open.value = false
|
||||
definiteRejection.value = false; reselectAllowed.value = false; rejectionEvidence.value = null
|
||||
warehouses.value = []; pallets.value = []; warehouseId.value = ''; target.value = null; sourceId.value = ''; keyword.value = ''; page.value = 1; total.value = 0
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey())
|
||||
if (!saved) { recoverServerCommand(); return }
|
||||
const value: { pending: Pending | null; definiteRejection?: boolean } = JSON.parse(saved)
|
||||
if (value.pending && (!['outbound', 'receipt'].includes(value.pending.type) || !value.pending.body?.idempotencyKey)) throw new Error('invalid saved command')
|
||||
pending.value = value.pending; definiteRejection.value = value.definiteRejection === true; recoverServerCommand()
|
||||
} catch { error.value = { message: '本次操作记录无法读取,请联系管理员核对原操作,暂不能提交新操作。' }; busy.value = true }
|
||||
}
|
||||
function recoverServerCommand(command?: WarehouseTransferReceiptCommandDto) {
|
||||
if (pending.value?.type === 'receipt' && props.detail.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === pending.value?.body.idempotencyKey)) { pending.value = null; persist() }
|
||||
const server = command ?? (unfinished.value.length === 1 ? unfinished.value[0] : undefined)
|
||||
if (server && canManage.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
pending.value = { type: 'receipt', body: JSON.parse(JSON.stringify(server.request)) as ReceiveWarehouseTransferPayload }
|
||||
notice.value = '已从本票服务端记录恢复原实收操作。'
|
||||
}
|
||||
}
|
||||
watch(() => [props.customerId, props.detail.transferNo], restore, { immediate: true })
|
||||
watch(() => props.detail.receiptCommands, () => { if (!busy.value) recoverServerCommand() })
|
||||
async function checkCurrent() {
|
||||
if (busy.value) return
|
||||
const current = generation, customerId = props.customerId, no = props.detail.transferNo
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = await getWarehouseTransfer(customerId, no)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new Error('返回的转运单不一致')
|
||||
const command = pending.value
|
||||
reselectAllowed.value = !!(command?.type === 'receipt' && definiteRejection.value && Array.isArray(value.receiptCommands)
|
||||
&& !value.receiptCommands.some(c => c.request.idempotencyKey === command.body.idempotencyKey)
|
||||
&& !value.receiptCommands.some(c => c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)
|
||||
&& value.allocations?.some(a => a.inventoryPositionId === command.body.sourceInventoryPositionId && a.status === 'SHIPPED'))
|
||||
if ((command?.type === 'outbound' && ['OUTBOUNDED', 'PARTIALLY_RECEIVED', 'RECEIVED'].includes(value.status))
|
||||
|| (command?.type === 'receipt' && value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey))) { pending.value = null; persist() }
|
||||
emit('updated', value)
|
||||
notice.value = reselectAllowed.value ? '服务端确认原请求未建立实收命令,来源仍待实收,可重新选择目标。' : pending.value ? '已核对当前状态;原操作仍保留,可使用原内容确认结果。' : '当前转运状态已刷新。'
|
||||
}
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '当前转运状态核对失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function reselectTarget() {
|
||||
if (!reselectAllowed.value || busy.value || pending.value?.type !== 'receipt' || !definiteRejection.value) return
|
||||
const original = pending.value
|
||||
if (error.value) rejectionEvidence.value = error.value
|
||||
try { pending.value = null; definiteRejection.value = false; persist() }
|
||||
catch (cause) { pending.value = original; definiteRejection.value = true; error.value = getApiErrorDetails(cause, '原操作记录无法更新,暂不能重新选择'); return }
|
||||
reselectAllowed.value = false; target.value = null; sourceId.value = ''; keyword.value = ''; pallets.value = []; error.value = null
|
||||
await openReceipt()
|
||||
}
|
||||
async function openReceipt() {
|
||||
if (!receiptReady.value || busy.value || pending.value) return
|
||||
open.value = true; busy.value = true; error.value = null; const current = generation
|
||||
try { const result = await listTransferReceivingWarehouses(); if (current !== generation) return; warehouses.value = result.records; if (previous.value) { warehouseId.value = previous.value.warehouseId; await selectPallet(previous.value.palletId) } }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货仓加载失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function searchPallets(nextPage = 1) {
|
||||
if (!warehouseId.value || pending.value || !canResources.value || busy.value) return
|
||||
const current = generation, warehouse = warehouseId.value
|
||||
busy.value = true; error.value = null; target.value = null
|
||||
try { const result = await listPallets(warehouse, { page: nextPage, size: 20, keyword: keyword.value.trim() || undefined, status: 'IDLE' }); if (current !== generation || warehouse !== warehouseId.value) return; pallets.value = result.records; total.value = result.total; page.value = nextPage }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '目标托盘查询失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function selectPallet(id: string) {
|
||||
if (pending.value || !canResources.value) return
|
||||
const current = generation, warehouse = warehouseId.value
|
||||
target.value = null; error.value = null; busy.value = true
|
||||
try {
|
||||
const w = warehouses.value.find(x => x.id === warehouse); if (!w) throw new Error('请选择有效收货仓')
|
||||
const p = await getPallet(warehouse, id); if (!p.locationId) throw new Error('目标托盘尚未绑定库位,请在托盘管理中核对')
|
||||
const l = await getOverseasLocation(warehouse, p.locationId)
|
||||
if (current !== generation || warehouse !== warehouseId.value) return
|
||||
const value = { warehouse: w, pallet: p, location: l }
|
||||
if (!receiptTargetValid(props.detail, value, acknowledgedTarget(value))) throw new Error('目标托盘、库位状态或 FBA 目的仓不符合本票要求')
|
||||
target.value = value
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '收货资源核对失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
function changeWarehouse() { target.value = null; pallets.value = []; page.value = 1; total.value = 0 }
|
||||
async function submit(type: 'outbound' | 'receipt') {
|
||||
if (busy.value || !canManage.value || (type === 'receipt' && !canResources.value)) return
|
||||
if (!pending.value) {
|
||||
definiteRejection.value = false; reselectAllowed.value = false
|
||||
try {
|
||||
if (type === 'outbound') { if (!outboundReady.value) return; pending.value = { type, body: { idempotencyKey: crypto.randomUUID() } } }
|
||||
else { if (!receiptReady.value || !target.value || !selectedValid.value) return; pending.value = { type, body: createReceiptCommand(props.detail, sourceId.value, target.value, crypto.randomUUID(), acknowledgedTarget(target.value)) } }
|
||||
persist() // Preserve the exact command before transport, including across close/navigation/reload.
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '原操作记录保存失败,未提交请求'); pending.value = null; return }
|
||||
}
|
||||
const command = pending.value
|
||||
if (command.type !== type) return
|
||||
const current = generation, customer = props.customerId, no = props.detail.transferNo
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const value = command.type === 'outbound' ? await outboundWarehouseTransfer(customer, no, command.body) : await receiveWarehouseTransfer(customer, no, command.body)
|
||||
if (current !== generation) return
|
||||
if (value.transferNo !== no) throw new ApiResponseError('操作响应与当前转运单不一致,请核对原操作', 'TRANSFER_IDENTITY_MISMATCH')
|
||||
if (command.type === 'receipt' && !value.receiptCommands?.some(c => c.status === 'COMPLETED' && c.request.idempotencyKey === command.body.idempotencyKey && c.sourceInventoryPositionId === command.body.sourceInventoryPositionId)) throw new ApiResponseError('实收响应尚未返回本次完成记录,请保留原操作并只读核对', 'TRANSFER_RECEIPT_RECORD_MISSING')
|
||||
pending.value = null; persist(); emit('updated', value); open.value = false; target.value = null; sourceId.value = ''
|
||||
notice.value = command.type === 'outbound' ? '本票库存已确认出库。' : '本来源目标实收已确认。'
|
||||
} catch (cause) { if (current === generation) { pending.value = command; definiteRejection.value = definiteBusinessRejection(cause); reselectAllowed.value = false; error.value = getApiErrorDetails(cause, '操作结果尚未确认,已保留原内容,请核对后重试原操作'); if (definiteRejection.value) rejectionEvidence.value = error.value; try { persist() } catch { /* Existing pre-transport record remains authoritative for retry. */ } } }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section aria-label="转运出库与目标实收" class="movement-panel">
|
||||
<template v-if="canManage">
|
||||
<el-button v-if="outboundReady && !pending" :disabled="busy" @click="open = !open">确认本票出库</el-button>
|
||||
<el-button v-if="receiptReady && !pending" :disabled="busy" @click="openReceipt">登记目标仓实收</el-button>
|
||||
<div v-if="open && outboundReady && !pending" aria-label="出库确认"><p>{{ detail.transferNo }} · {{ detail.packageCount }} 箱。确认实际移出后,全部已分配来源将发运。</p><el-button :loading="busy" @click="submit('outbound')">确认实际出库</el-button></div>
|
||||
<div v-if="open && receiptReady && !pending" aria-label="目标仓实收选择">
|
||||
<el-form label-position="top" class="movement-fields">
|
||||
<el-form-item label="已发运来源"><el-select v-model="sourceId" aria-label="已发运来源" :disabled="busy" placeholder="请选择来源"><el-option v-for="a in shipped" :key="a.inventoryPositionId" :value="a.inventoryPositionId" :label="`${a.inboundCode || '—'} / ${a.palletNo || '—'} / ${a.skuCode || '—'} · ${a.packageCount} 箱`" /></el-select></el-form-item>
|
||||
<el-form-item label="真实收货仓"><el-select v-model="warehouseId" aria-label="真实收货仓" :disabled="busy" placeholder="请选择收货仓" @change="changeWarehouse"><el-option v-for="w in warehouses" :key="w.id" :value="w.id" :label="`${w.warehouseCode} · ${w.warehouseName}`" /></el-select></el-form-item>
|
||||
<el-form-item label="目标托盘码查询"><el-input v-model="keyword" aria-label="目标托盘码查询" :disabled="busy" placeholder="输入托盘码查询" @keyup.enter="searchPallets()" /><el-button :disabled="busy || !warehouseId" @click="searchPallets()">查询目标托盘</el-button></el-form-item>
|
||||
<el-form-item label="目标托盘"><el-select :model-value="target?.pallet.id || ''" aria-label="目标托盘" :disabled="busy" placeholder="请选择查询结果" @update:model-value="selectPallet(String($event))"><el-option v-for="p in pallets" :key="p.id" :value="p.id" :label="`${p.palletCode || '—'} · ${p.fbaWarehouseCode || '—'}`" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="page > 1" :disabled="busy" @click="searchPallets(page - 1)">上一页</el-button><el-button v-if="page * 20 < total" :disabled="busy" @click="searchPallets(page + 1)">下一页</el-button>
|
||||
<template v-for="t in completedTargets" :key="t.palletId"><el-button v-if="warehouseId === t.warehouseId" :disabled="busy" @click="selectPallet(t.palletId)">核对本票已收目标 {{ t.palletNo }}</el-button></template>
|
||||
<p v-if="target">目标:{{ target.warehouse.warehouseCode }} / {{ target.pallet.palletCode }} / {{ target.location.locationCode }} · FBA {{ target.pallet.fbaWarehouseCode }}</p>
|
||||
<p>按当前来源全部箱数实收,库位使用目标托盘已绑定的真实库位。</p><el-button :disabled="busy || !selectedValid" :loading="busy" @click="submit('receipt')">确认本来源实收</el-button>
|
||||
</div>
|
||||
<p v-if="pending">原操作内容已保留,请先核对当前状态,再使用原内容确认结果。{{ pending.type === 'receipt' ? `${pending.body.destinationPalletNo} / ${pending.body.warehouseLocationCode}` : detail.transferNo }}</p>
|
||||
<template v-if="unfinished.length > 1 && !pending"><p>本票存在多条未完成实收,请选择需要恢复的原操作。</p><el-button v-for="c in unfinished" :key="c.request.idempotencyKey" :disabled="busy" @click="recoverServerCommand(c)">恢复 {{ c.target.palletNo }} / {{ c.target.locationCode }}</el-button></template>
|
||||
<el-button v-if="pending" :disabled="busy || (pending.type === 'receipt' && !canResources)" :loading="busy" @click="submit(pending.type)">重试原操作</el-button>
|
||||
<el-button v-if="pending" :disabled="busy" @click="checkCurrent">只读核对当前状态</el-button>
|
||||
<el-button v-if="reselectAllowed && pending?.type === 'receipt'" :disabled="busy" @click="reselectTarget">重新选择目标</el-button>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !canResources">登记目标实收需要收货仓、托盘、库位查询及转运管理权限。</p>
|
||||
<p v-if="['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status) && !Array.isArray(detail.receiptCommands)">本票尚未返回服务端实收操作记录,请联系管理员核对接口后再登记实收。</p>
|
||||
</template>
|
||||
<p v-if="notice" role="status">{{ notice }}</p><div v-if="error" role="alert"><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><div v-if="rejectionEvidence"><p>上次目标选择被拒绝:{{ rejectionEvidence.message }}</p><ApiErrorMeta :details="rejectionEvidence" /></div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.movement-panel { margin-block: var(--admin-space-3); }
|
||||
.movement-fields { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: var(--admin-space-3); }
|
||||
.movement-fields :deep(.el-form-item) { margin-bottom: var(--admin-space-2); }
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canOutboundTransfer, createReceiptCommand, receiptTargetValid, type ReceiptTarget } from './warehouseTransferReceiptSelection'
|
||||
import type { WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
const detail = (): WarehouseTransferDetailDto => ({ id: 'hidden-id', transferNo: 'WT-1', needRelabel: true, relabelNo: 'RL-1', relabelStatus: 'READY_FOR_OUTBOUND', fbaWarehouseCode: 'ABE4', destinationWarehouseCode: 'ABE4', packageCount: 2, status: 'ALLOCATED', items: [], cartons: [], pallets: [], relabelBindings: [], allocations: [1, 2].map(i => ({ inventoryPositionId: 'POS-' + i, inboundOrderId: 'INTERNAL', palletId: 'LA-' + i, packageCount: 1, status: 'RESERVED', skuCode: 'NEW' })) })
|
||||
const target = (): ReceiptTarget => ({ warehouse: { id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, pallet: { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 }, location: { id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'IDLE', version: 0 } })
|
||||
describe('transfer movement eligibility', () => {
|
||||
it('blocks unconfirmed relabel and incomplete allocation before outbound', () => { const d = detail(); expect(canOutboundTransfer(d)).toBe(true); d.relabelStatus = 'CUSTOMER_CONFIRM_PENDING'; expect(canOutboundTransfer(d)).toBe(false); d.relabelStatus = 'READY_FOR_OUTBOUND'; d.allocations!.pop(); expect(canOutboundTransfer(d)).toBe(false) })
|
||||
it('uses only shipped unreceived source with exact physical warehouse, FBA and binding', () => { const d = detail(), t = target(); d.status = 'PARTIALLY_RECEIVED'; d.allocations![0]!.status = 'RECEIVED'; d.allocations![1]!.status = 'SHIPPED'; expect(() => createReceiptCommand(d, 'POS-1', t, 'key')).toThrow(); expect(createReceiptCommand(d, 'POS-2', t, 'key')).toMatchObject({ sourceInventoryPositionId: 'POS-2', destinationOverseasWarehouseId: 'NY', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY', destinationWarehouseCode: 'ABE4', idempotencyKey: 'key' }); t.pallet.locationId = 'wrong'; expect(receiptTargetValid(d, t)).toBe(false) })
|
||||
it('rejects another occupied target and permits only the server acknowledged shared target', () => { const d = detail(), t = target(), previous = { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' }; t.pallet.status = 'OCCUPIED'; t.location.status = 'OCCUPIED'; expect(receiptTargetValid(d, t)).toBeFalsy(); expect(receiptTargetValid(d, t, previous)).toBe(true); t.pallet.id = 'OTHER'; expect(receiptTargetValid(d, t, previous)).toBeFalsy() })
|
||||
it('rejects ACTIVE physical location and wrong FBA; supports ACTIVE virtual location', () => { const d = detail(), t = target(); t.location.status = 'ACTIVE'; expect(receiptTargetValid(d, t)).toBeFalsy(); t.location.locationType = 'VIRTUAL'; expect(receiptTargetValid(d, t)).toBeTruthy(); t.pallet.fbaWarehouseCode = 'OTHER'; expect(receiptTargetValid(d, t)).toBe(false) })
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { WarehouseTransferDetailDto, ReceiveWarehouseTransferPayload, WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers'
|
||||
import type { PalletDto, ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import type { OverseasLocationDto } from '../api/locations'
|
||||
|
||||
export type ReceiptTarget = { warehouse: ReceivingOverseasWarehouseDto; pallet: PalletDto; location: OverseasLocationDto }
|
||||
export function canOutboundTransfer(detail: WarehouseTransferDetailDto) {
|
||||
return detail.status === 'ALLOCATED' && !!detail.fbaWarehouseCode
|
||||
&& (!detail.needRelabel || (!!detail.relabelNo && ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(detail.relabelStatus || '')))
|
||||
&& !!detail.allocations?.length && detail.allocations.every(a => a.status === 'RESERVED')
|
||||
&& detail.allocations.reduce((sum, a) => sum + a.packageCount, 0) === detail.packageCount
|
||||
}
|
||||
export function receiptTargetValid(detail: WarehouseTransferDetailDto, target: ReceiptTarget, previous?: WarehouseTransferReceiptCommandDto['target'] | null) {
|
||||
const { warehouse: w, pallet: p, location: l } = target
|
||||
const same = previous && previous.warehouseId === w.id && previous.palletId === p.id && previous.locationId === l.id
|
||||
return w.warehouseType === 'OVERSEAS' && w.status === 'ACTIVE' && w.customerId === '0'
|
||||
&& p.warehouseId === w.id && l.warehouseId === w.id && p.locationId === l.id && p.customerId === '0'
|
||||
&& !!p.palletCode && !!l.locationCode && p.fbaWarehouseCode === detail.destinationWarehouseCode
|
||||
&& (p.status === 'IDLE' || (same && p.status === 'OCCUPIED'))
|
||||
&& (l.locationType === 'VIRTUAL' ? l.status === 'ACTIVE' : (l.status === 'IDLE' || (same && l.status === 'OCCUPIED')))
|
||||
}
|
||||
export function createReceiptCommand(detail: WarehouseTransferDetailDto, sourceId: string, target: ReceiptTarget, key: string, previous?: WarehouseTransferReceiptCommandDto['target'] | null): ReceiveWarehouseTransferPayload {
|
||||
if (!['OUTBOUNDED', 'PARTIALLY_RECEIVED'].includes(detail.status)
|
||||
|| !detail.allocations?.some(a => a.inventoryPositionId === sourceId && a.status === 'SHIPPED')
|
||||
|| !detail.destinationWarehouseCode || !receiptTargetValid(detail, target, previous)) throw new Error('来源或目标收货资源不符合本票要求,请重新核对')
|
||||
return { sourceInventoryPositionId: sourceId, destinationOverseasWarehouseId: target.warehouse.id,
|
||||
destinationOverseasWarehouseCode: target.warehouse.warehouseCode, destinationOverseasWarehouseName: target.warehouse.warehouseName,
|
||||
destinationWarehouseId: detail.destinationWarehouseId || undefined, destinationWarehouseCode: detail.destinationWarehouseCode,
|
||||
destinationWarehouseName: detail.destinationWarehouseName || undefined, destinationPalletId: target.pallet.id, destinationPalletNo: target.pallet.palletCode,
|
||||
warehouseLocationId: target.location.id, warehouseLocationCode: target.location.locationCode, batchNo: detail.transferNo, idempotencyKey: key }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { physicalWeightKg, physicalWeightLb, totalPhysicalWeightLb } from './dispatchPhysicalWeight'
|
||||
|
||||
describe('dispatch physical weight', () => {
|
||||
it('keeps absent or invalid physical weight unknown', () => {
|
||||
for (const value of [null, undefined, '', 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(physicalWeightKg(value)).toBeNull()
|
||||
expect(physicalWeightLb(10)).toBeCloseTo(22.046226218)
|
||||
})
|
||||
it('never substitutes billing volume weight and does not publish partial totals', () => {
|
||||
const unknown = { grossWeightKg: null, volumeWeightKg: 999 }
|
||||
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, unknown])).toBeNull()
|
||||
expect(totalPhysicalWeightLb([undefined])).toBeNull()
|
||||
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, { grossWeightKg: 30 }])).toBeCloseTo(110.23113109)
|
||||
expect(totalPhysicalWeightLb([])).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
const kilogramsToPounds = 2.2046226218
|
||||
|
||||
export function physicalWeightKg(value: unknown): number | null {
|
||||
if (value == null || value === '') return null
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) && number > 0 ? number : null
|
||||
}
|
||||
|
||||
export function physicalWeightLb(value: unknown): number | null {
|
||||
const weight = physicalWeightKg(value)
|
||||
return weight === null ? null : weight * kilogramsToPounds
|
||||
}
|
||||
|
||||
export function totalPhysicalWeightLb(items: readonly ({ grossWeightKg?: number | null } | undefined)[]): number | null {
|
||||
let total = 0
|
||||
for (const item of items) {
|
||||
const weight = physicalWeightLb(item?.grossWeightKg)
|
||||
if (weight === null) return null
|
||||
total += weight
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fullyCoveredServices, type FeeCoveragePreview, type FeeCoverageSelection } from './feeCoverageTypes'
|
||||
|
||||
const preview: FeeCoveragePreview = {
|
||||
dispatchNo: 'DP-1', sourceComplete: true, blockReason: null, cargoHash: 'hash',
|
||||
cargo: ['11', '12'].map(id => ({ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: id, sourceRegion: 'FIRSTLEG', sourceInboundId: id, inboundCode: `IN-${id}`, packages: 10, dispatchInboundCode: 'IN-11' })),
|
||||
feeSources: ['11', '12'].map(id => ({ financeFeeId: `7${id}`, sourceFeeId: `8${id}`, sourceType: 'INBOUND_FEE', sourceVersion: 1, customerId: '101', sourceInboundId: id, inboundCode: `IN-${id}`, feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'source-hash', status: 'AVAILABLE' })),
|
||||
}
|
||||
describe('per-inbound service coverage', () => {
|
||||
it('does not hide new fees when only one of two shared-pallet sources is covered', () => {
|
||||
const selections: FeeCoverageSelection[] = [{ financeFeeId: '711', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] }]
|
||||
expect(fullyCoveredServices(preview, selections, [])).toEqual([])
|
||||
selections.push({ financeFeeId: '712', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] })
|
||||
expect(fullyCoveredServices(preview, selections, [])).toEqual(['DELIVERY_FEE'])
|
||||
})
|
||||
it('external freight covers delivery only, and incomplete sources never imply original fee coverage', () => {
|
||||
expect(fullyCoveredServices(preview, [], ['IN-11'])).toEqual(['DELIVERY_FEE'])
|
||||
expect(fullyCoveredServices({ ...preview, sourceComplete: false }, [], ['IN-11'])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export type CoveredService = 'DELIVERY_FEE' | 'LOADING_FEE' | 'APPOINTMENT_FEE'
|
||||
export const coveredServiceLabels: Record<CoveredService, string> = { DELIVERY_FEE: '派送', LOADING_FEE: '装车', APPOINTMENT_FEE: '买约' }
|
||||
export interface CoverageCargo {
|
||||
dispatchItemId: string; warehousePalletId: string; palletNo: string | null; orderId: string | null
|
||||
sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; packages: number; dispatchInboundCode: string | null
|
||||
}
|
||||
export interface OriginalFeeSource {
|
||||
financeFeeId: string; sourceFeeId: string; sourceType: string; sourceVersion: number; customerId: string
|
||||
sourceInboundId: string; inboundCode: string | null; feeItemCode: string; feeItemName: string; currency: string
|
||||
amount: number; sourceHash: string; status: string
|
||||
}
|
||||
export interface FeeCoveragePreview { dispatchNo: string; sourceComplete: boolean; blockReason: string | null; cargoHash: string; cargo: CoverageCargo[]; feeSources: OriginalFeeSource[] }
|
||||
export interface FeeCoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: CoveredService[] }
|
||||
export interface FeeCoverageEvidence { cargoHash: string; cargo: CoverageCargo[]; references: Array<{ source: OriginalFeeSource; services: CoveredService[] }>; confirmedAt: string }
|
||||
|
||||
export function fullyCoveredServices(preview: FeeCoveragePreview | null, selections: FeeCoverageSelection[], externalIncomingNos: string[]): CoveredService[] {
|
||||
if (!preview?.sourceComplete || !preview.cargo.length) return []
|
||||
const byOrigin = new Map<string, Set<CoveredService>>()
|
||||
for (const selection of selections) {
|
||||
const source = preview.feeSources.find(fee => fee.financeFeeId === selection.financeFeeId)
|
||||
if (!source) continue
|
||||
const included = byOrigin.get(source.sourceInboundId) || new Set<CoveredService>()
|
||||
selection.services.forEach(service => included.add(service))
|
||||
byOrigin.set(source.sourceInboundId, included)
|
||||
}
|
||||
return (Object.keys(coveredServiceLabels) as CoveredService[]).filter(service => preview.cargo.every(cargo =>
|
||||
service === 'DELIVERY_FEE' && externalIncomingNos.includes(cargo.dispatchInboundCode || '')
|
||||
|| cargo.sourceRegion === 'FIRSTLEG' && !!cargo.sourceInboundId && byOrigin.get(cargo.sourceInboundId)?.has(service)))
|
||||
}
|
||||
@@ -15,12 +15,12 @@ describe('DeliveryOrderDetailView', () => {
|
||||
order: {
|
||||
version: 3, allowedActions: ['COMPLETE_DELIVERY'],
|
||||
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
|
||||
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 2,
|
||||
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 2, signedPackages: null,
|
||||
createdAt: '2026-09-01T10:00:00',
|
||||
},
|
||||
skus: [], fees: [
|
||||
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 },
|
||||
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 },
|
||||
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'PENDING_FINANCE' },
|
||||
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'FINANCE_ATTENTION' },
|
||||
], operations: [],
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,9 @@ describe('DeliveryOrderDetailView', () => {
|
||||
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
|
||||
expect(wrapper.text()).toContain('私人仓尾程派送费')
|
||||
expect(wrapper.text()).toContain('派送费用')
|
||||
expect(wrapper.text()).toContain('作废待财务确认')
|
||||
expect(wrapper.text()).toContain('作废待财务处理')
|
||||
expect(wrapper.text()).not.toContain('已作废')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -47,4 +50,17 @@ describe('DeliveryOrderDetailView', () => {
|
||||
expect(wrapper.text()).toContain('缺少派送单客户范围')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows frozen original coverage separately from new fees', async () => {
|
||||
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
|
||||
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
|
||||
source: { financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '100', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original', status: 'AVAILABLE' }, services: ['DELIVERY_FEE', 'LOADING_FEE'],
|
||||
}] }
|
||||
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
|
||||
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
|
||||
expect(wrapper.text()).toContain('原费用金额供核对,不计作本次新增')
|
||||
expect(wrapper.text()).toContain('IN-11 · 原运费 · CNY 100 · 版本 3 · 已含派送、装车')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
|
||||
import { coveredServiceLabels } from '../feeCoverageTypes'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -26,10 +27,17 @@ const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER
|
||||
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
|
||||
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
|
||||
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
|
||||
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
|
||||
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
|
||||
const resultLabels: Record<string, string> = { CREATED: '已创建' }
|
||||
|
||||
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
|
||||
function feeStatus(fee: DeliveryFeeRow) {
|
||||
if (fee.voidStatus === 'CONFIRMED') return '已作废'
|
||||
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
|
||||
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
|
||||
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
|
||||
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
|
||||
}
|
||||
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
|
||||
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
|
||||
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
|
||||
@@ -95,10 +103,14 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
|
||||
|
||||
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
|
||||
<section class="tab-content" aria-label="派送单费用明细">
|
||||
<template v-if="detail?.feeCoverage?.references.length">
|
||||
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
|
||||
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
|
||||
</template>
|
||||
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
|
||||
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
|
||||
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column prop="remark" label="备注" min-width="240" />
|
||||
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
|
||||
</el-table>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOr
|
||||
const row = {
|
||||
version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY', 'MARK_RETURN'],
|
||||
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
|
||||
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00',
|
||||
status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, signedPackages: null, createdAt: '2026-09-01T10:00:00',
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
|
||||
@@ -29,4 +29,15 @@ describe('DispatchDetailView', () => {
|
||||
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { physicalWeightKg } from '../dispatchPhysicalWeight'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -13,7 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
interface DetailField { label: string; value: string | number }
|
||||
interface RelatedOrderRow {
|
||||
orderNo: string
|
||||
weight: number
|
||||
weight: number | null
|
||||
palletCount: number
|
||||
fbaWarehouse: string
|
||||
volume: number
|
||||
@@ -84,21 +85,22 @@ const documentFields = computed<DetailField[]>(() => [
|
||||
])
|
||||
|
||||
const relatedOrders = computed<RelatedOrderRow[]>(() => {
|
||||
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
|
||||
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)
|
||||
current.weight += Number(item.grossWeightKg || 0)
|
||||
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: Number(value.weight.toFixed(3)),
|
||||
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
|
||||
palletCount: value.pallets.size,
|
||||
fbaWarehouse: [...value.destinations].join('、') || '—',
|
||||
volume: Number(value.volume.toFixed(3)),
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { 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 { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
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(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
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() {}
|
||||
@@ -52,10 +56,56 @@ describe('DispatchesView', () => {
|
||||
vi.mocked(removeDispatchItem).mockReset()
|
||||
vi.mocked(cancelDispatch).mockReset()
|
||||
vi.mocked(transferDispatchToDelivery).mockReset()
|
||||
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: [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 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: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
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(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[], ['查看', '打印BOL'], false],
|
||||
[['edit'], ['查看', '打印BOL', '编辑'], false],
|
||||
@@ -150,6 +200,54 @@ describe('DispatchesView', () => {
|
||||
expect(source).toContain('请确认海外尾程排车接口已发布')
|
||||
})
|
||||
|
||||
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: [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: [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 and transfer with independent permissions and service statuses', async () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
|
||||
expect(source).toContain('permission.dispatchEdit')
|
||||
|
||||
@@ -14,7 +14,9 @@ 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, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
|
||||
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'
|
||||
@@ -29,6 +31,31 @@ const loadError = 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 reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
|
||||
let loadSequence = 0
|
||||
function switchStage(value: typeof stage.value) {
|
||||
if (stage.value === value) return
|
||||
stage.value = value
|
||||
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 } })
|
||||
}
|
||||
const filters = ref<QueryModel>({
|
||||
dispatchNo: '',
|
||||
vehiclePlate: '',
|
||||
@@ -38,6 +65,15 @@ const filters = ref<QueryModel>({
|
||||
const transferOpen = ref(false)
|
||||
const transferRow = ref<DispatchListItem | null>(null)
|
||||
const transferSaving = ref(false)
|
||||
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)
|
||||
@@ -157,6 +193,9 @@ const fields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
|
||||
{ key: 'workflow', label: '关联车次', minWidth: 210, slotName: 'workflow' },
|
||||
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
|
||||
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
|
||||
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
||||
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
|
||||
@@ -217,6 +256,13 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
||||
]
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
if (section.value === 'vehicle') return columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
|
||||
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
|
||||
return keys.map(key => columns.find(column => column.key === key)!)
|
||||
})
|
||||
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
|
||||
|
||||
function text(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || '—'
|
||||
@@ -252,6 +298,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) {
|
||||
|
||||
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
||||
return {
|
||||
workflow: dto.workflow,
|
||||
id: String(dto.id),
|
||||
dispatchNo: text(dto.dispatchNo),
|
||||
vehiclePlate: text(dto.vehiclePlate),
|
||||
@@ -284,6 +331,7 @@ function dispatchError(error: unknown) {
|
||||
}
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const sequence = ++loadSequence
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
@@ -291,21 +339,24 @@ async function load(model: QueryModel = filters.value) {
|
||||
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 {
|
||||
loading.value = false
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +424,7 @@ function openEdit(row: DispatchListItem) {
|
||||
mutationError.value = null
|
||||
editOpen.value = true
|
||||
}
|
||||
function openTransfer(row: DispatchListItem) {
|
||||
async function openTransfer(row: DispatchListItem) {
|
||||
if (!canTransfer(row)) return
|
||||
transferRow.value = row
|
||||
Object.assign(transferForm, {
|
||||
@@ -397,6 +448,19 @@ function openTransfer(row: DispatchListItem) {
|
||||
})
|
||||
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
|
||||
@@ -477,6 +541,13 @@ async function cancel(row: DispatchListItem) {
|
||||
}
|
||||
}
|
||||
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 },
|
||||
@@ -486,7 +557,8 @@ function rowActions(row: DispatchListItem): RowActionItem[] {
|
||||
]
|
||||
}
|
||||
function handleRowAction(command: string, row: DispatchListItem) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
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 === 'edit') openEdit(row)
|
||||
else if (command === 'transfer') openTransfer(row)
|
||||
@@ -494,26 +566,27 @@ function handleRowAction(command: string, row: DispatchListItem) {
|
||||
}
|
||||
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 (transferForm.deliveryFee == null) {
|
||||
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
|
||||
ElMessage.warning('请填写派送费用')
|
||||
return
|
||||
}
|
||||
if (needsLoadingFee.value && transferForm.loadingFee == null) {
|
||||
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
|
||||
ElMessage.warning('当前装车类型需要填写装车费')
|
||||
return
|
||||
}
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && transferForm.appointmentFee == null)) {
|
||||
ElMessage.warning('买约时请填写卖家姓名和买约费用')
|
||||
return
|
||||
}
|
||||
const feeInputs = [
|
||||
['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'],
|
||||
['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
|
||||
['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用'],
|
||||
['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)
|
||||
@@ -544,6 +617,9 @@ async function submitTransfer() {
|
||||
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
|
||||
ElMessage.success('已生成出库单和派送单')
|
||||
@@ -561,15 +637,34 @@ onMounted(load)
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="visibleColumns" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>排车列表</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></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 sections" :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>
|
||||
<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)" /></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 #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable>
|
||||
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @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><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" 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>
|
||||
</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><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)" @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>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from '../api/exceptionOrders'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail } from '../api/exceptionOrders'
|
||||
import ExceptionOrdersView from './ExceptionOrdersView.vue'
|
||||
import { commonPhase5Stubs } from './phase5TestStubs'
|
||||
|
||||
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn() }))
|
||||
vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
|
||||
|
||||
function mountView() { return mount(ExceptionOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
|
||||
|
||||
describe('ExceptionOrdersView permissions and creation', () => {
|
||||
beforeEach(() => {
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
vi.mocked(getExceptionDetail).mockReset().mockResolvedValue({ order: { id: '19', exceptionNo: 'EXC-1', customerId: '100', customerName: '示例客户',
|
||||
exceptionType: 'DAMAGED', status: 'PROCESSING', reason: '包装破损', handlerName: '1000001', version: 1, allowedActions: ['COMPLETE_EXCEPTION'], exceptionAt: '', updatedAt: '' }, returnSources: [], returnReceipts: [] })
|
||||
vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' })
|
||||
vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
|
||||
|
||||
@@ -17,6 +17,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import ExceptionProcessingPanel from '../components/ExceptionProcessingPanel.vue'
|
||||
|
||||
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -24,7 +25,7 @@ const filters = ref<QueryModel>({ keyword: '', exceptionType: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '异常单号 / 关联单号 / 客户 / 原因' },
|
||||
{ key: 'exceptionType', type: 'select', label: '异常类型', placeholder: '全部类型', options: [{ label: '退货', value: 'RETURN' }, { label: '拒收', value: 'REJECT' }, { label: '破损', value: 'DAMAGED' }, { label: '其他', value: 'OTHER' }] },
|
||||
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }] },
|
||||
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
|
||||
@@ -44,7 +45,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) },
|
||||
]
|
||||
const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' }
|
||||
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成' }
|
||||
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const detailOpen = ref(false); const detailRow = ref<ExceptionOrderRow | null>(null)
|
||||
function openDetail(row: ExceptionOrderRow) { detailRow.value = row; detailOpen.value = true }
|
||||
function displayHandler(value: unknown) {
|
||||
@@ -108,18 +109,7 @@ onMounted(load)
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
|
||||
<el-descriptions class="density-descriptions" v-if="detailRow" :column="2" border>
|
||||
<el-descriptions-item label="异常订单号">{{ detailRow.exceptionNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">{{ statusLabels[String(detailRow.status)] || detailRow.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常类型">{{ typeLabels[detailRow.exceptionType] || detailRow.exceptionType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ detailRow.customerName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="海外仓">{{ detailRow.overseasWarehouseName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ displayHandler(detailRow.handlerName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detailRow.exceptionAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detailRow.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常原因" :span="2">{{ detailRow.reason || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理动作" :span="2">{{ detailRow.handlingAction || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,16 +3,30 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLastMileOrder } from '../api/lastMileOrders'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
import { getWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
|
||||
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
|
||||
vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
it('loads transfer fulfillment only on the plan tab using sourceOrderNo, never the tail order number', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-transfer-tail', customerId: 'customer-1', orderNo: 'WT-TAIL-1', sourceOrderNo: 'WT-SOURCE-1', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue({ id: 'internal-transfer', transferNo: 'WT-SOURCE-1', needRelabel: true, status: 'WAIT_RELABEL', relabelNo: 'RL-1', relabelStatus: 'CUSTOMER_CONFIRM_PENDING', packageCount: 2, items: [], cartons: [], pallets: [], relabelBindings: [] })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(getWarehouseTransfer).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('[role="tab"]').find(x => x.text().includes('计划信息'))!.trigger('click'); await flushPromises()
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-SOURCE-1')
|
||||
expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('待客户确认')
|
||||
expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('不得提前出库')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockReset()
|
||||
vi.mocked(getWarehouseTransfer).mockReset()
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({
|
||||
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
|
||||
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
|
||||
|
||||
@@ -13,10 +13,11 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
|
||||
import { relabelApplicationStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
|
||||
import { relabelApplicationStatusLabel, relabelBusinessStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
@@ -117,7 +118,7 @@ const planFields = computed<DetailField[]>(() => {
|
||||
{ label: '合托批次', value: text(order?.palletMergeFlag) },
|
||||
{ label: '计划托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '入库单号', value: text(order?.inboundOrderNo) },
|
||||
{ label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : lastMileStatusLabel(order?.relabelStatus) },
|
||||
{ label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : relabelBusinessStatusLabel(order?.relabelStatus) },
|
||||
]
|
||||
})
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
@@ -215,7 +216,8 @@ onMounted(load)
|
||||
</dl>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"></DataTable></section></el-tab-pane>
|
||||
<el-tab-pane name="plan"><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
|
||||
<el-tab-pane name="plan" lazy><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
|
||||
<WarehouseTransferFulfillmentPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="detail.order.customerId" :transfer-no="detail.order.sourceOrderNo || ''" />
|
||||
<section class="info-panel plan-panel" aria-label="计划信息"><dl class="detail-fields plan-fields density-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
|
||||
<section class="table-panel related-panel"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></section>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -12,11 +12,13 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
downloadLastMileOrders,
|
||||
convertLastMileContainerToInboundPlans,
|
||||
getContainerInboundPlanPreview,
|
||||
getContainerPlanProgress,
|
||||
listContainerPlanProgress,
|
||||
listLastMileOrders,
|
||||
reviewCargoDiscard,
|
||||
transferLastMileOrders,
|
||||
getLastMileOrderManualNodes,
|
||||
updateLastMileOrderHold,
|
||||
@@ -26,6 +28,7 @@ import LastMileOrdersView from './LastMileOrdersView.vue'
|
||||
|
||||
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
@@ -34,6 +37,7 @@ vi.mock('../api/lastMileOrders', () => ({
|
||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
||||
downloadLastMileOrders: vi.fn(),
|
||||
reviewCargoDiscard: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -89,6 +93,26 @@ const preview = {
|
||||
}],
|
||||
}],
|
||||
}
|
||||
// Recorded LOCAL_COMMITTED shape: complete local plan/pallet, zero new work, PLANNED,
|
||||
// and an explicit server resourceRetry holding the original command identity.
|
||||
const resourceCommittedPreview = {
|
||||
...preview, snapshotVersion: 10, snapshotHash: 'b'.repeat(64),
|
||||
totalOrderCount: 1, readyOrderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0,
|
||||
sourcePalletCount: 0, supplementPalletCount: 0, existingPlannedPalletCount: 1,
|
||||
totalSuggestedPalletCount: 1, totalPlannedPalletCount: 1,
|
||||
groups: [{
|
||||
...preview.groups[0], destinationType: 'FBA' as const,
|
||||
orderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0, sourcePalletCount: 0,
|
||||
supplementPalletCount: 0, existingPlannedPalletCount: 1, suggestedPalletCount: 1, plannedPalletCount: 1,
|
||||
orders: [{ ...preview.groups[0].orders[0], destinationType: 'FBA' as const, fbaWarehouseCode: 'ONT8',
|
||||
packageCount: 1, suggestedPalletCount: 1, sourcePalletCount: 1, targetPalletCount: 1,
|
||||
supplementPalletCount: 0, plannedPalletCount: 1, existingPlanNo: 'LIP-ORIGINAL',
|
||||
planStatus: 'PENDING_ASSIGNMENT', readiness: 'PLANNED' as const, blockers: [],
|
||||
}],
|
||||
}],
|
||||
resourceRetry: { idempotencyKey: 'original-container-command', snapshotVersion: 9, snapshotHash: 'a'.repeat(64) },
|
||||
}
|
||||
const originalResourcePlans = [{ planNo: 'LIP-ORIGINAL', pallets: [{ id: 'PALLET-ORIGINAL', palletNo: 'PLT-ORIGINAL' }] }]
|
||||
const receivingWarehouse = {
|
||||
...fbaWarehouse,
|
||||
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
|
||||
@@ -118,7 +142,7 @@ async function mountView() {
|
||||
|
||||
async function openPending() {
|
||||
const wrapper = await mountView()
|
||||
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
|
||||
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')
|
||||
await pendingTab!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
@@ -126,7 +150,7 @@ async function openPending() {
|
||||
|
||||
async function openHiddenContainerWorkbench(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
const view = wrapper.vm as unknown as {
|
||||
statusTab: '全部' | '待分配' | '柜子'
|
||||
statusTab: '全部' | '开船前' | '开船后' | '柜子'
|
||||
changeStatusTab(): void
|
||||
}
|
||||
view.statusTab = '柜子'
|
||||
@@ -142,6 +166,9 @@ async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows:
|
||||
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
|
||||
return wrapper.findAll('button').find((item) => item.text().includes(text))
|
||||
}
|
||||
function cabinetDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '按柜生成入库计划预览')!
|
||||
}
|
||||
|
||||
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
await button(wrapper, '按柜批量生成')!.trigger('click')
|
||||
@@ -159,6 +186,95 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
}
|
||||
|
||||
describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
|
||||
const wrapper = await mountView()
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '开船前', '开船后'])
|
||||
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await tabs[0]!.trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(tabs[1]!.classes()).toContain('is-active')
|
||||
expect(document.activeElement).toBe(tabs[1]!.element)
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE', currentStatus: undefined }))
|
||||
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
|
||||
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
|
||||
await tabs[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(callCount)
|
||||
await tabs[1]!.trigger('keydown', { key: 'Home' })
|
||||
await flushPromises()
|
||||
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined, sailingStage: undefined, orderType: undefined }))
|
||||
await tabs[0]!.trigger('keydown', { key: 'End' })
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ sailingStage: 'AFTER_DEPARTURE', orderType: 'FIRST_LEG' }))
|
||||
expect(tabs[2]!.attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('exports the same stage and filters as the list and restores other types in all', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), orderType: 'RETURN_RELABEL', detained: 'false' })
|
||||
await wrapper.vm.$nextTick()
|
||||
for (const [label, stage] of [['开船前', 'BEFORE_DEPARTURE'], ['开船后', 'AFTER_DEPARTURE']] as const) {
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false }))
|
||||
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
|
||||
await button(wrapper, '导出订单')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(downloadLastMileOrders).toHaveBeenLastCalledWith(vi.mocked(listLastMileOrders).mock.lastCall![0])
|
||||
}
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'RETURN_RELABEL', sailingStage: undefined }))
|
||||
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
|
||||
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
|
||||
await button(wrapper, '高级筛选')!.trigger('click')
|
||||
expect(toolbar.props('fields')).toHaveLength(13)
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
|
||||
createdAt: ['2026-09-01', '2026-09-30'],
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
await button(wrapper, '收起筛选')!.trigger('click')
|
||||
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
|
||||
expect(toolbar.props('modelValue').containerNo).toBe(' CONT-15 ')
|
||||
await button(wrapper, '查询')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
containerNo: 'CONT-15', abnormal: false,
|
||||
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-30T23:59:59',
|
||||
}))
|
||||
await button(wrapper, '重置')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls + 2)
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
page: 1, containerNo: undefined, abnormal: undefined, createdFrom: undefined, createdTo: undefined,
|
||||
}))
|
||||
await button(wrapper, '高级筛选')!.trigger('click')
|
||||
expect(toolbar.props('modelValue').containerNo).toBe('')
|
||||
expect(toolbar.props('modelValue').createdAt).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps container filters separate from the advanced order fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
await button(wrapper, '高级筛选')!.trigger('click')
|
||||
await openHiddenContainerWorkbench(wrapper)
|
||||
expect(wrapper.findComponent(QueryToolbar).props('fields').map((field) => field.key)).toEqual(['containerNo', 'createdAt'])
|
||||
expect(button(wrapper, '收起筛选')).toBeUndefined()
|
||||
expect(button(wrapper, '高级筛选')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the original carton count and adds only the signed difference in the list', async () => {
|
||||
canAccess.mockReturnValue(false)
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
@@ -189,6 +305,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(reviewCargoDiscard).mockReset().mockResolvedValue({})
|
||||
canAccess.mockReset().mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
@@ -212,6 +330,92 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||
})
|
||||
|
||||
async function openDiscard(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1', sourceOrderNo: 'CD-TEST-1',
|
||||
orderType: 'CARGO_DISCARD', currentStatus: 'PENDING_REVIEW', inboundOrderNo: 'CD-TEST-1', ...overrides,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
return mountView()
|
||||
}
|
||||
|
||||
it.each([
|
||||
['lastleg:cargo-discard:approve', 'PENDING_REVIEW', true],
|
||||
['platform:overseas-warehouse:last-mile-order:edit', 'PENDING_REVIEW', false],
|
||||
['lastleg:cargo-discard:approve', 'APPROVED', false],
|
||||
['lastleg:cargo-discard:approve', 'CANCELLED', false],
|
||||
])('gates discard review on exact permission %s and status %s', async (code, currentStatus, visible) => {
|
||||
canAccess.mockImplementation(permissionCode => permissionCode === code)
|
||||
const wrapper = await openDiscard({ currentStatus })
|
||||
expect(Boolean(button(wrapper, '审核'))).toBe(visible)
|
||||
expect(reviewCargoDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([{ orderNo: '' }, { customerId: '' }])('does not expose discard review without a business target %j', async (overrides) => {
|
||||
const wrapper = await openDiscard(overrides)
|
||||
expect(button(wrapper, '审核')).toBeUndefined()
|
||||
expect(reviewCargoDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('approves the selected discard once and reloads the server status', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
const wrapper = await openDiscard()
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1',
|
||||
orderType: 'CARGO_DISCARD', currentStatus: 'APPROVED', inboundOrderNo: 'CD-TEST-1',
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核', expect.objectContaining({ distinguishCancelAndClose: true }))
|
||||
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'APPROVE', undefined)
|
||||
expect(button(wrapper, '审核')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects with a trimmed mandatory reason without approving first', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: ' 箱数不符 ', action: 'confirm' } as Awaited<ReturnType<typeof ElMessageBox.prompt>>)
|
||||
const wrapper = await openDiscard()
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
const options = prompt.mock.calls[0]?.[2]
|
||||
expect(options?.inputValidator?.(' ')).not.toBe(true)
|
||||
expect(options?.inputValidator?.('甲'.repeat(1001))).not.toBe(true)
|
||||
expect(options?.inputValidator?.('箱数不符')).toBe(true)
|
||||
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'REJECT', '箱数不符')
|
||||
})
|
||||
|
||||
it('closing review or cancelling the reason makes no request', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('close').mockRejectedValueOnce('cancel')
|
||||
vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel')
|
||||
const wrapper = await openDiscard()
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(reviewCargoDiscard).not.toHaveBeenCalled()
|
||||
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('locks repeat clicks while review is pending and allows retry after an API failure', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||
const errorToast = vi.spyOn(ElMessage, 'error')
|
||||
let rejectRequest!: (error: Error) => void
|
||||
vi.mocked(reviewCargoDiscard).mockImplementationOnce(() => new Promise((_, reject) => { rejectRequest = reject }))
|
||||
const wrapper = await openDiscard()
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(true)
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
expect(reviewCargoDiscard).toHaveBeenCalledTimes(1)
|
||||
rejectRequest(new Error('审批请求失败'))
|
||||
await flushPromises()
|
||||
expect(errorToast).toHaveBeenCalled()
|
||||
expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
|
||||
await button(wrapper, '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(reviewCargoDiscard).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PENDING_ASSIGNMENT', false],
|
||||
['INBOUND_COMPLETED', true],
|
||||
@@ -267,7 +471,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).not.toContain('按订单')
|
||||
expect(wrapper.text()).not.toContain('待分配整柜')
|
||||
expect(wrapper.text()).not.toContain('柜子工作台')
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: 'PENDING_ASSIGNMENT' }))
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' }))
|
||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -300,9 +504,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
]))
|
||||
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
|
||||
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
|
||||
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', sailingStage: 'BEFORE_DEPARTURE', orderType: 'FIRST_LEG', lxCode: 'LX-SEARCH' }))
|
||||
|
||||
await openHiddenContainerWorkbench(wrapper)
|
||||
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({
|
||||
@@ -310,7 +514,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
|
||||
})
|
||||
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click')
|
||||
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '全部')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
|
||||
|
||||
@@ -566,6 +770,131 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect((button(wrapper, '修改接柜仓')!.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the original resource command after reopening a locally committed cabinet', async () => {
|
||||
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({
|
||||
records: [{ ...summary, pendingPlanCount: 0, resourcePendingPlanCount: 1, allPlansReady: true }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValueOnce(resourceCommittedPreview)
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
|
||||
const wrapper = await mountView()
|
||||
await openHiddenContainerWorkbench(wrapper)
|
||||
await button(wrapper, '重试生成托盘码')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
|
||||
expect(cabinetDialog(wrapper).props('confirmText')).toBe('重试资源确认')
|
||||
cabinetDialog(wrapper).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
|
||||
sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
|
||||
}, 'original-container-command')
|
||||
})
|
||||
|
||||
it('reopens a complete local plan and confirms only the original resource key/version/hash without showing zero generation', async () => {
|
||||
const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never))
|
||||
vi.mocked(getContainerInboundPlanPreview)
|
||||
.mockResolvedValueOnce(resourceCommittedPreview)
|
||||
.mockResolvedValueOnce(resourceCommittedPreview)
|
||||
.mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
|
||||
const wrapper = await openPending()
|
||||
await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const dialog = cabinetDialog(wrapper)
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(dialog.props('confirmText')).toBe('重试资源确认')
|
||||
expect(document.body.textContent).toContain('资源仍待确认')
|
||||
expect(document.body.textContent).not.toContain('最终生成 0 个托盘')
|
||||
expect(document.body.textContent).not.toContain('最终 0 托')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledExactlyOnceWith({
|
||||
sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
|
||||
}, 'original-container-command')
|
||||
expect(document.body.textContent).toContain('资源确认完成')
|
||||
expect(document.body.textContent).toContain('PLT-ORIGINAL')
|
||||
expect(cabinetDialog(wrapper).props('confirmText')).toBe('已确认')
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
expect(success).toHaveBeenCalledWith('已完成 1 个已有计划的资源确认')
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成')
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never)
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
const identity = document.body.querySelector('.container-preview__identity')!
|
||||
expect(identity.textContent).toBe('未录入货柜号')
|
||||
expect(identity.getAttribute('title')).toBe('未录入货柜号')
|
||||
expect(identity.getAttribute('title')).not.toContain(bookingId)
|
||||
})
|
||||
|
||||
it('keeps server readiness blocks effective even for a resource retry', async () => {
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 })
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('guards resource retry at the handler when the caller lacks create permission', async () => {
|
||||
canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create')
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
|
||||
const wrapper = await openPending()
|
||||
const view = wrapper.vm as unknown as { openContainerConversion(): void }
|
||||
view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preview and candidate loading guards effective for recovery commands', async () => {
|
||||
let resolvePreview!: (value: typeof resourceCommittedPreview) => void
|
||||
let resolveOptions!: (value: Awaited<ReturnType<typeof listContainerPlanProgress>>) => void
|
||||
vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve }))
|
||||
vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve }))
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
resolvePreview(resourceCommittedPreview); await flushPromises()
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
resolveOptions({ records: [summary], page: 1, size: 50, total: 1, pages: 1 }); await flushPromises()
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the original recovery command on failure and blocks duplicate or completed submissions', async () => {
|
||||
const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never))
|
||||
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503))
|
||||
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(cabinetDialog(wrapper).props('modelValue')).toBe(true)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
let resolveCommand!: (value: Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>) => void
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockReturnValueOnce(new Promise(resolve => { resolveCommand = resolve }))
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
|
||||
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
|
||||
resolveCommand(originalResourcePlans as never); await flushPromises()
|
||||
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
|
||||
for (const args of vi.mocked(convertLastMileContainerToInboundPlans).mock.calls) expect(args).toEqual([
|
||||
{ sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64) }, 'original-container-command',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the all-cabinet empty state and unit', async () => {
|
||||
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
const wrapper = await mountView()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Pencil, PackagePlus } from '@lucide/vue'
|
||||
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -15,8 +15,9 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||
import { confirmRelabelCompletion, createReturnPackingPlan, getRelabelReviewTask, returnRelabelForRework, type ReturnPackingPlanBox } from '../api/relabelOrders'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -70,7 +71,7 @@ async function submitReturnPackingPlan() {
|
||||
}
|
||||
const actionLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
|
||||
async function exportOrders() { exportLoading.value = true; try { saveCsv(await downloadLastMileOrders(orderQuery()) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const inboundPlanOpen = ref(false)
|
||||
@@ -123,13 +124,20 @@ interface ContainerConversionResult {
|
||||
existingPlanNos: Set<string>
|
||||
newPlanCount: number
|
||||
existingPlanCount: number
|
||||
resourceRecovered: boolean
|
||||
}
|
||||
const containerConversionResult = ref<ContainerConversionResult | null>(null)
|
||||
let containerPreviewRequest = 0
|
||||
let containerPreviewLoadingBookingId = ''
|
||||
let containerConversionIdempotencyKey = ''
|
||||
const containerConfirmDisabled = computed(() => !containerPreview.value?.ready
|
||||
|| containerPreview.value.pendingOrderCount <= 0
|
||||
const containerIsResourceRecovery = computed(() => Boolean(containerPreview.value?.resourceRetry || containerConversionResult.value?.resourceRecovered))
|
||||
const containerConfirmText = computed(() => containerConversionResult.value
|
||||
? containerConversionResult.value.resourceRecovered ? '已确认' : '已生成'
|
||||
: containerPreview.value?.resourceRetry ? '重试资源确认' : '确认生成')
|
||||
const containerConfirmDisabled = computed(() => !access.can(permission.inboundOrderCreate)
|
||||
|| !containerPreview.value?.ready
|
||||
|| (!containerPreview.value.resourceRetry && containerPreview.value.pendingOrderCount <= 0)
|
||||
|| containerOptionsLoading.value
|
||||
|| containerPreviewLoading.value
|
||||
|| actionLoading.value
|
||||
|| Boolean(containerConversionResult.value))
|
||||
@@ -142,7 +150,9 @@ const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(nul
|
||||
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
||||
let receivingWarehouseIdempotencyKey = ''
|
||||
let containerOptionRequest = 0
|
||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
||||
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
|
||||
const statusTabs = ['全部', '开船前', '开船后'] as const
|
||||
const showAdvancedFilters = ref(false)
|
||||
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
const allFields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
|
||||
@@ -174,9 +184,12 @@ const allFields: QueryField[] = [
|
||||
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
|
||||
]
|
||||
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
|
||||
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
||||
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
|
||||
: allFields)
|
||||
: showAdvancedFilters.value
|
||||
? availableFields.value
|
||||
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
|
||||
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
|
||||
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
|
||||
row.warehouseId === receivingWarehouseForm.warehouseId
|
||||
@@ -321,6 +334,29 @@ function changeContainerPageSize(value: number) {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function orderQuery(): LastMileOrderQuery {
|
||||
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
||||
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
|
||||
return {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
||||
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
|
||||
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG',
|
||||
sailingStage: statusTab.value === '开船前' ? 'BEFORE_DEPARTURE' : statusTab.value === '开船后' ? 'AFTER_DEPARTURE' : undefined,
|
||||
warehouse: String(filters.value.warehouse || '').trim() || undefined,
|
||||
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
||||
lxCode: String(filters.value.lxCode || '').trim() || undefined,
|
||||
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
|
||||
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
||||
currentStatus: String(filters.value.status || '').trim() || undefined,
|
||||
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
|
||||
abnormal: booleanValue(filters.value.abnormal),
|
||||
detained: booleanValue(filters.value.detained),
|
||||
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
|
||||
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
|
||||
}
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
@@ -347,26 +383,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
||||
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
|
||||
const result = await listLastMileOrders({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
||||
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
|
||||
orderType: String(filters.value.orderType || '').trim() || undefined,
|
||||
warehouse: String(filters.value.warehouse || '').trim() || undefined,
|
||||
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
||||
lxCode: String(filters.value.lxCode || '').trim() || undefined,
|
||||
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
|
||||
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
||||
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
|
||||
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
|
||||
abnormal: booleanValue(filters.value.abnormal),
|
||||
detained: booleanValue(filters.value.detained),
|
||||
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
|
||||
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
|
||||
})
|
||||
const result = await listLastMileOrders(orderQuery())
|
||||
rows.value = result.records.map(toLastMileOrderListItem)
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
@@ -381,12 +398,35 @@ async function load(model: QueryModel = filters.value) {
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset() {
|
||||
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function changeStatusTab() {
|
||||
selectedRows.value = []
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function selectStatusTab(value: typeof statusTabs[number]) {
|
||||
if (statusTab.value === value) return
|
||||
statusTab.value = value
|
||||
changeStatusTab()
|
||||
}
|
||||
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
|
||||
let nextIndex = index
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = statusTabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
const tab = statusTabs[nextIndex]
|
||||
if (!tab) return
|
||||
selectStatusTab(tab)
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
}
|
||||
|
||||
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
|
||||
containerDetailOpen.value = true
|
||||
@@ -712,7 +752,8 @@ async function loadContainerPreview(sourceContainerBookingId = selectedContainer
|
||||
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
|
||||
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
|
||||
containerPreview.value = preview
|
||||
if (!preserveResult) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
|
||||
if (!preserveResult) containerConversionIdempotencyKey = preview.resourceRetry
|
||||
? preview.resourceRetry.idempotencyKey : idempotencyKey('container-inbound-plan')
|
||||
} catch (error) {
|
||||
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
|
||||
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
|
||||
@@ -726,9 +767,12 @@ async function loadContainerPreview(sourceContainerBookingId = selectedContainer
|
||||
|
||||
async function confirmContainerInboundPlans() {
|
||||
const preview = containerPreview.value
|
||||
if (!preview?.ready || preview.pendingOrderCount <= 0 || actionLoading.value) return
|
||||
if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
|
||||
if (!preview || containerConfirmDisabled.value) return
|
||||
if (preview.resourceRetry) containerConversionIdempotencyKey = preview.resourceRetry.idempotencyKey
|
||||
else if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
|
||||
const resourceRecovery = Boolean(preview.resourceRetry)
|
||||
const bookingId = preview.sourceContainerBookingId
|
||||
const commandSnapshot = preview.resourceRetry ?? preview
|
||||
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
|
||||
.filter((order) => order.readiness !== 'READY' && order.existingPlanNo)
|
||||
.map((order) => String(order.existingPlanNo)))
|
||||
@@ -737,8 +781,8 @@ async function confirmContainerInboundPlans() {
|
||||
try {
|
||||
const plans = await convertLastMileContainerToInboundPlans({
|
||||
sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: preview.snapshotVersion,
|
||||
snapshotHash: preview.snapshotHash,
|
||||
snapshotVersion: commandSnapshot.snapshotVersion,
|
||||
snapshotHash: commandSnapshot.snapshotHash,
|
||||
}, containerConversionIdempotencyKey)
|
||||
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
|
||||
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
|
||||
@@ -747,8 +791,11 @@ async function confirmContainerInboundPlans() {
|
||||
existingPlanNos,
|
||||
newPlanCount: plans.length - existingPlanCount,
|
||||
existingPlanCount,
|
||||
resourceRecovered: resourceRecovery,
|
||||
}
|
||||
ElMessage.success(`已生成 ${plans.length - existingPlanCount} 个入库计划`)
|
||||
ElMessage.success(resourceRecovery
|
||||
? `已完成 ${plans.length} 个已有计划的资源确认`
|
||||
: `已生成 ${plans.length - existingPlanCount} 个入库计划`)
|
||||
containerPreview.value = null
|
||||
await Promise.all([load(), loadContainerPreview(bookingId, true)])
|
||||
} catch (error) {
|
||||
@@ -816,6 +863,44 @@ watch(containerConvertOpen, (open) => {
|
||||
containerConversionIdempotencyKey = ''
|
||||
})
|
||||
|
||||
const discardReviewBusy = ref(false)
|
||||
function canReviewDiscard(row: LastMileOrderListItem) {
|
||||
return access.can(permission.cargoDiscardApprove)
|
||||
&& row.orderTypeCode === 'CARGO_DISCARD'
|
||||
&& row.currentStatusCode === 'PENDING_REVIEW'
|
||||
&& Boolean(row.orderNo && row.orderNo !== '—')
|
||||
&& /^[1-9]\d*$/.test(row.customerId)
|
||||
}
|
||||
async function reviewDiscard(row: LastMileOrderListItem) {
|
||||
if (discardReviewBusy.value || !canReviewDiscard(row)) return
|
||||
discardReviewBusy.value = true
|
||||
try {
|
||||
let decision: 'APPROVE' | 'REJECT' = 'APPROVE'
|
||||
let remark: string | undefined
|
||||
try {
|
||||
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, '废弃申请审核', {
|
||||
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
|
||||
})
|
||||
} catch (action) {
|
||||
if (action !== 'cancel') throw action
|
||||
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃申请', {
|
||||
customClass: 'admin-density-confirm', inputType: 'textarea',
|
||||
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
|
||||
confirmButtonText: '确认驳回', cancelButtonText: '取消',
|
||||
})
|
||||
decision = 'REJECT'
|
||||
remark = value.trim()
|
||||
}
|
||||
await reviewCargoDiscard(row.orderNo, row.customerId, decision, remark)
|
||||
ElMessage.success(decision === 'APPROVE' ? '废弃申请已审核通过' : '废弃申请已驳回')
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '废弃申请审核失败').message)
|
||||
} finally {
|
||||
discardReviewBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewReturn(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
const returnNo = row.sourceOrderNo
|
||||
@@ -879,6 +964,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
|
||||
const inboundPlanCreated = hasInboundPlan(row)
|
||||
return [
|
||||
{ key: 'detail', label: '查看', priority: 500 },
|
||||
{ key: 'review-discard', label: '审核', priority: 480, tone: 'warning', visible: canReviewDiscard(row), disabled: discardReviewBusy.value },
|
||||
{ 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' },
|
||||
@@ -891,6 +977,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
|
||||
}
|
||||
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
else if (command === 'review-discard') void reviewDiscard(row)
|
||||
else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
|
||||
else if (command === 'review-return') void reviewReturn(row)
|
||||
else if (command === 'review') openTransferReview(row)
|
||||
@@ -944,13 +1031,22 @@ onMounted(() => { void load() })
|
||||
<template #toolbar-start><h1>尾程订单</h1></template>
|
||||
<template #filters>
|
||||
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
|
||||
<el-radio-button value="全部">全部</el-radio-button>
|
||||
<el-radio-button value="待分配">待分配</el-radio-button>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
|
||||
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
|
||||
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
|
||||
:class="{ 'is-active': statusTab === tab }"
|
||||
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
|
||||
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
|
||||
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
|
||||
</el-radio-group>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
</div>
|
||||
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
|
||||
<template v-if="statusTab !== '柜子'" #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||||
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
</section>
|
||||
</template>
|
||||
<template #notices><template v-if="loadError">
|
||||
@@ -997,7 +1093,7 @@ onMounted(() => { void load() })
|
||||
</div>
|
||||
</section></template>
|
||||
|
||||
<template v-if="statusTab !== '柜子'" #toolbar-actions><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)" 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="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template v-if="statusTab !== '柜子'" #toolbar-actions><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)" 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 }} · {{ application.quantity }} 件</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>
|
||||
@@ -1022,7 +1118,7 @@ onMounted(() => { void load() })
|
||||
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
|
||||
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
|
||||
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
|
||||
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
|
||||
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
|
||||
@@ -1037,19 +1133,24 @@ onMounted(() => { void load() })
|
||||
<el-alert v-if="containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" />
|
||||
<div v-if="containerPreview" class="container-preview">
|
||||
<div class="container-preview__context">
|
||||
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || containerPreview.sourceContainerBookingId">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
|
||||
<div class="container-preview__identity text-ellipsis" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</div>
|
||||
<div class="container-preview__warehouse text-ellipsis" :title="[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'">{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</div>
|
||||
</div>
|
||||
<div class="container-preview__metrics">
|
||||
<div v-if="!containerIsResourceRecovery" class="container-preview__metrics">
|
||||
<div class="container-preview__metric"><span>头程已有托盘码</span><strong>{{ containerPreview.sourcePalletCount }}</strong></div>
|
||||
<div class="container-preview__metric container-preview__metric--primary"><span>海外仓补充托盘码</span><strong>{{ containerPreview.supplementPalletCount }}</strong></div>
|
||||
<div class="container-preview__metric"><span>最终生成托盘</span><strong>{{ containerPreview.pendingPalletCount }}</strong></div>
|
||||
<div class="container-preview__metric"><span>本次处理订单</span><strong>{{ containerPreview.pendingOrderCount }}</strong></div>
|
||||
</div>
|
||||
<div v-else class="container-preview__metrics">
|
||||
<div class="container-preview__metric"><span>已保存计划托盘</span><strong>{{ containerPreview.totalPlannedPalletCount }}</strong></div>
|
||||
<div class="container-preview__metric container-preview__metric--primary"><span>资源确认</span><strong>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</strong></div>
|
||||
</div>
|
||||
<el-alert v-if="containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
|
||||
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" class="container-convert-alert" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
|
||||
<el-alert v-else-if="!containerConversionResult" class="container-convert-alert" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
|
||||
<section v-if="containerConversionResult" class="container-conversion-result">
|
||||
<header><strong>生成完成</strong><span>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
|
||||
<header><strong>{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</strong><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></header>
|
||||
<div class="container-conversion-result__plans">
|
||||
<article v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan">
|
||||
<div><strong class="text-ellipsis" :title="plan.planNo">{{ plan.planNo }}</strong><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></div>
|
||||
@@ -1061,8 +1162,9 @@ onMounted(() => { void load() })
|
||||
<div class="container-preview__scroll">
|
||||
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group">
|
||||
<header class="container-preview__group-header">
|
||||
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:最终 {{ group.pendingPalletCount }} 托</strong>
|
||||
<span>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
|
||||
<strong class="text-ellipsis" :title="previewGroupTitle(group)">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</strong>
|
||||
<span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span>
|
||||
<span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span>
|
||||
</header>
|
||||
<el-table :data="group.orders" size="small" border>
|
||||
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
|
||||
@@ -1125,7 +1227,7 @@ onMounted(() => { void load() })
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
|
||||
<el-form class="command-form density-form" label-position="top">
|
||||
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致。</small></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。</small><el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -30,8 +30,8 @@ describe('OutboundDetailView', () => {
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
|
||||
deliveries: [
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-ONT8', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ONT8', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '3', customerId: 'customer-1', deliveryNo: 'DEL-ACY2', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-ONT8', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ONT8', palletCount: 1, packageCount: 10, signedPackages: null, createdAt: '2026-09-01T10:00:00' },
|
||||
{ version: 2, allowedActions: ['START_DELIVERY'], id: '3', customerId: 'customer-1', deliveryNo: 'DEL-ACY2', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 10, signedPackages: null, createdAt: '2026-09-01T10:00:00' },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ describe('OutboundDetailView', () => {
|
||||
])('uses a matching warehouse name and never displays internal IDs (%s, %s)', async (deliveryWarehouseId, name, expected) => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', overseasWarehouseId: 'warehouse-internal-id' },
|
||||
deliveries: [{ id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00', version: 1, allowedActions: [], overseasWarehouseId: deliveryWarehouseId, overseasWarehouseName: name }],
|
||||
deliveries: [{ id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, signedPackages: null, createdAt: '2026-09-01T10:00:00', version: 1, allowedActions: [], overseasWarehouseId: deliveryWarehouseId, overseasWarehouseName: name }],
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -208,7 +208,7 @@ async function submit() {
|
||||
}
|
||||
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
|
||||
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
|
||||
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
|
||||
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
onMounted(load)
|
||||
|
||||
@@ -28,8 +28,10 @@ describe('container inbound plan preview contract', () => {
|
||||
expect(pageSource).toContain('request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value')
|
||||
expect(pageSource).toContain('containerPreviewRequest += 1')
|
||||
expect(pageSource.match(/await convertLastMileContainerToInboundPlans\(\{/g)).toHaveLength(1)
|
||||
expect(pageSource).toContain('snapshotVersion: preview.snapshotVersion')
|
||||
expect(pageSource).toContain('snapshotHash: preview.snapshotHash')
|
||||
expect(pageSource).toContain('const commandSnapshot = preview.resourceRetry ?? preview')
|
||||
expect(pageSource).toContain('snapshotVersion: commandSnapshot.snapshotVersion')
|
||||
expect(pageSource).toContain('snapshotHash: commandSnapshot.snapshotHash')
|
||||
expect(pageSource).toContain('preview.resourceRetry.idempotencyKey')
|
||||
expect(pageSource).toContain('containerConversionIdempotencyKey')
|
||||
expect(pageSource).toContain(':confirm-disabled="containerConfirmDisabled"')
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
])
|
||||
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
|
||||
const value = source(fileName)
|
||||
const toolbar = value.match(/<QueryToolbar\b[^>]*\/>/)?.[0]
|
||||
const toolbar = value.match(/<QueryToolbar\b[^>]*>/)?.[0]
|
||||
expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
|
||||
expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
|
||||
return preferenceKey
|
||||
@@ -254,7 +254,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
expect(dialog).toContain('listDispatchCandidates')
|
||||
expect(dialog).toContain('preArrangeDispatch')
|
||||
expect(dialog).toContain('planPalletIds: assignedIds.value')
|
||||
expect(dialog).toContain("planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain("returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT')")
|
||||
expect(dialog).toContain('lockDispatchDraftPallet')
|
||||
expect(dialog).toContain('unlockDispatchDraftPallet')
|
||||
expect(dialog).not.toContain('releaseDispatchDraft')
|
||||
@@ -374,8 +375,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
|
||||
expect(value).toContain('请选择目的 FBA 仓')
|
||||
expect(value).toContain('aria-label="尾程订单状态"')
|
||||
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
|
||||
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
|
||||
expect(value).toContain("const statusTabs = ['全部', '开船前', '开船后'] as const")
|
||||
expect(value).toContain('class="density-status-tabs" role="tablist"')
|
||||
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
|
||||
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
|
||||
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))
|
||||
|
||||
@@ -3,6 +3,7 @@ export const overseasWarehousePermissions = {
|
||||
lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
|
||||
lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit',
|
||||
warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve',
|
||||
cargoDiscardApprove: 'lastleg:cargo-discard:approve',
|
||||
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
|
||||
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
|
||||
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface InboundOrderListItem {
|
||||
}
|
||||
|
||||
export interface DispatchListItem {
|
||||
workflow?: import('./api/dispatches').DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
vehiclePlate: string
|
||||
|
||||
Reference in New Issue
Block a user