feat(overseas): select stock transfer sources and follow relabel

This commit is contained in:
2026-10-04 21:19:11 +08:00
parent d3a2671fd7
commit 939cc11d7b
9 changed files with 574 additions and 2 deletions
@@ -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<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('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,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<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 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 }
}
@@ -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<typeof import('@/shared/api/http')>()), 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: [] } })
@@ -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<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/allocations`,
payload,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) {