From 93f193c5d62649d4b7a57cd13b3dc10fa57b6db3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 06:16:21 +0800 Subject: [PATCH] feat(transfer): expose stock outbound and durable receipt recovery UI --- .../api/warehouseTransfers.test.ts | 18 +- .../api/warehouseTransfers.ts | 64 ++++++ .../WarehouseTransferFulfillmentPanel.vue | 5 +- .../WarehouseTransferMovementPanel.test.ts | 123 ++++++++++++ .../WarehouseTransferMovementPanel.vue | 183 ++++++++++++++++++ .../warehouseTransferReceiptSelection.test.ts | 11 ++ .../warehouseTransferReceiptSelection.ts | 30 +++ 7 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.test.ts create mode 100644 src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.vue create mode 100644 src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.test.ts create mode 100644 src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.ts diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts index a994d5fc..0fa70473 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts @@ -1,11 +1,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer } from './warehouseTransfers' +import { allocateWarehouseTransfer, getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } 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('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' } }) diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.ts index 57da206a..09791948 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.ts @@ -2,6 +2,7 @@ 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 @@ -85,8 +86,20 @@ 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 { @@ -94,6 +107,57 @@ export interface AllocateWarehouseTransferPayload { 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>( + `/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>( + `/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>>( + '/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 & { packageCount: number | string } function normalizeTransferDetail(value: WarehouseTransferDetailWire): WarehouseTransferDetailDto { return { ...value, packageCount: normalizeWarehouseTransferQuantity(value.packageCount, '本票转运箱数') } diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue index cda519dd..5f3f295d 100644 --- a/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue +++ b/src/modules/overseas-warehouse/components/WarehouseTransferFulfillmentPanel.vue @@ -12,6 +12,7 @@ import { allocateWarehouseTransfer, getWarehouseTransfer, type AllocateWarehouse 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() @@ -117,12 +118,13 @@ const gateText = computed(() => { 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 : '—' }))) +const allocationRows = computed(() => (detail.value?.allocations || []).map(row => ({ ...row, skuCode: row.skuCode && row.skuCode !== 'MIXED' ? row.skuCode : '—', movementStatus: ({ RESERVED: '已分配', SHIPPED: '已发运', RECEIVED: '目标已实收' } as Record)[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 @@ -159,6 +161,7 @@ onBeforeUnmount(() => { request++; sourceRequest++ })

{{ gateText }}

查看本票换标单 + diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.test.ts b/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.test.ts new file mode 100644 index 00000000..b5bc3590 --- /dev/null +++ b/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.test.ts @@ -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, 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('登记目标仓实收') + }) +}) diff --git a/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.vue b/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.vue new file mode 100644 index 00000000..7ebe83f7 --- /dev/null +++ b/src/modules/overseas-warehouse/components/WarehouseTransferMovementPanel.vue @@ -0,0 +1,183 @@ + + + + diff --git a/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.test.ts b/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.test.ts new file mode 100644 index 00000000..d1a7a5a3 --- /dev/null +++ b/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.test.ts @@ -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) }) +}) diff --git a/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.ts b/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.ts new file mode 100644 index 00000000..73f5227e --- /dev/null +++ b/src/modules/overseas-warehouse/components/warehouseTransferReceiptSelection.ts @@ -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 } +}