feat(overseas): select stock transfer sources and follow relabel
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user