From 939cc11d7b70058758831cd566410e1a0386e99b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 21:19:11 +0800 Subject: [PATCH] feat(overseas): select stock transfer sources and follow relabel --- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 8 + .../api/warehouseTransferSources.test.ts | 64 ++++++ .../api/warehouseTransferSources.ts | 64 ++++++ .../api/warehouseTransfers.test.ts | 28 ++- .../api/warehouseTransfers.ts | 17 ++ .../WarehouseTransferFulfillmentPanel.test.ts | 193 ++++++++++++++++++ .../WarehouseTransferFulfillmentPanel.vue | 184 +++++++++++++++++ .../pages/LastMileOrderDetailView.test.ts | 14 ++ .../pages/LastMileOrderDetailView.vue | 4 +- 9 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 src/modules/overseas-warehouse/api/warehouseTransferSources.test.ts create mode 100644 src/modules/overseas-warehouse/api/warehouseTransferSources.ts create mode 100644 src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.test.ts create mode 100644 src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index 7dc81808..3d276af2 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -1,5 +1,13 @@ # 海外仓原型核对矩阵 +### 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 与库存页面承载;正式范围包含尾程订单、入库单、换标、转运、退货、托盘、库存、排车、出库、派送、异常退回、客户回访和一件代发。 diff --git a/src/modules/overseas-warehouse/api/warehouseTransferSources.test.ts b/src/modules/overseas-warehouse/api/warehouseTransferSources.test.ts new file mode 100644 index 00000000..17eddbce --- /dev/null +++ b/src/modules/overseas-warehouse/api/warehouseTransferSources.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listWarehouseTransferSources } from './warehouseTransferSources' +import type { WarehouseTransferDetailDto } from './warehouseTransfers' + +vi.mock('@/shared/api/http', async original => ({ ...await original(), 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('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() + }) +}) diff --git a/src/modules/overseas-warehouse/api/warehouseTransferSources.ts b/src/modules/overseas-warehouse/api/warehouseTransferSources.ts new file mode 100644 index 00000000..733d43b4 --- /dev/null +++ b/src/modules/overseas-warehouse/api/warehouseTransferSources.ts @@ -0,0 +1,64 @@ +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' + +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 = InventoryPositionDto & { customerId: 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(), issues: string[] = [] + for (const source of sources.values()) { + const representedIds = new Set() + let currentPage = 1, total = -1, recordsRead = 0 + do { + const page: OverseasPageResult = normalizeOverseasPage(unwrapApiResponse(await http.get>, ApiEnvelope>>( + '/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>(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>(`${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 reason = !position.palletId || !position.palletNo ? '缺少完整托盘业务码,待核查' + : !Number.isSafeInteger(position.availablePackages) || position.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: position.availablePackages, canSelect: !reason, blockReason: reason }) + } + } + currentPage++ + } while (recordsRead < total) + } + return { candidates: [...candidates.values()], issues } +} diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts index d90b08ed..07f15e0d 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts @@ -1,12 +1,38 @@ 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 } from './warehouseTransfers' vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('warehouseTransfers API', () => { beforeEach(() => vi.clearAllMocks()) + it('preserves the body idempotency key, exact source positions and selected customer on replay', async () => { + const value = { transferNo: 'WT-1', 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', 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: [] } }) diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.ts index cb69262f..ab6e7456 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.ts @@ -84,6 +84,23 @@ export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto relabelBindings: WarehouseTransferRelabelBindingDto[] customerConfirmedAt?: string | null actualShipAt?: 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 }> +} + +export interface AllocateWarehouseTransferPayload { + idempotencyKey: string + allocations: Array<{ inventoryPositionId: string; palletId: string; packageCount: number }> +} + +/** 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>( + `/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/allocations`, + payload, + { params: { customerId } }, + ) + return unwrapApiResponse(response) } export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) { diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.test.ts b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.test.ts new file mode 100644 index 00000000..652cabc6 --- /dev/null +++ b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue new file mode 100644 index 00000000..cda519dd --- /dev/null +++ b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts index 3ef22c02..2c73f5cb 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts @@ -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: [], diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 4ba3a7d6..cf98a8b5 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -17,6 +17,7 @@ import { relabelApplicationStatusLabel, formatOverseasDateTime, lastMileOrderRef 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 } @@ -215,7 +216,8 @@ onMounted(load)
- + +
{{ field.label }}
{{ field.value }}