fix(overseas): normalize transfer inventory quantities at API boundary
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
describe('warehouse transfer quantity wire normalization', () => {
|
||||
it.each([{ value: '3', result: 3 }, { value: '0', result: 0 }, { value: 3, result: 3 }, { value: 0, result: 0 }, { value: '9007199254740991', result: Number.MAX_SAFE_INTEGER }])('normalizes $value to a safe integer', ({ value, result }) => {
|
||||
expect(normalizeWarehouseTransferQuantity(value, '箱数')).toBe(result)
|
||||
})
|
||||
it.each([{ value: '' }, { value: ' ' }, { value: null }, { value: undefined }, { value: true }, { value: false }, { value: '1.5' }, { value: 1.5 }, { value: '-1' }, { value: -1 }, { value: '1e2' }, { value: ' 3' }, { value: '3 ' }, { value: '0x3' }, { value: '9007199254740992' }, { value: Number.MAX_SAFE_INTEGER + 1 }, { value: Infinity }, { value: NaN }, { value: [] }, { value: {} }])('rejects invalid or ambiguous $value', ({ value }) => {
|
||||
expect(() => normalizeWarehouseTransferQuantity(value, '箱数')).toThrow('箱数返回格式无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
export function normalizeWarehouseTransferQuantity(value: unknown, label: string): number {
|
||||
const quantity = typeof value === 'number' ? value
|
||||
: typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : Number.NaN
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 0) {
|
||||
throw new ApiResponseError(`${label}返回格式无效,请重新加载或联系管理员核查。`, 'TRANSFER_QUANTITY_INVALID')
|
||||
}
|
||||
return quantity
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Shape and string quantities from the original TEST allocation preflight;
|
||||
// customer, warehouse, order, SKU and resource identities are anonymized.
|
||||
export const transferSourceWireFixture = {
|
||||
customerId: 'fixture-customer',
|
||||
transfer: {
|
||||
id: 'fixture-transfer-id', transferNo: 'WT-FIXTURE', packageCount: 1,
|
||||
operatingWarehouseId: 'fixture-warehouse', status: 'APPROVED', needRelabel: true,
|
||||
items: [], cartons: [], pallets: [],
|
||||
sourceOrders: [{ lastlegOrderId: 'fixture-inbound', lastlegOrderNo: 'FL-FIXTURE', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE' }],
|
||||
relabelBindings: [{ operationType: 'SKU', customerCartonMark: 'BOX-FIXTURE', originalSku: 'SKU-FIXTURE-OLD', newSku: 'SKU-FIXTURE-NEW', quantity: 1 }],
|
||||
},
|
||||
balance: {
|
||||
id: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse',
|
||||
destinationWarehouseCode: 'ABE2', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE',
|
||||
skuCode: 'SKU-FIXTURE-OLD', productNameZh: '合成测试货物',
|
||||
totalPackages: '4', availablePackages: '3', holdPackages: '1', allocatedPackages: '0', pickedPackages: '0',
|
||||
},
|
||||
positions: [
|
||||
{ id: 'fixture-held-position', balanceId: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE', palletId: 'fixture-held-pallet', palletNo: 'DP-FIXTURE-0017', warehouseLocationCode: 'LOC-FIXTURE', totalPackages: '1', availablePackages: '0', holdPackages: '1', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', status: 'OCCUPIED' },
|
||||
{ id: 'fixture-available-position', balanceId: 'fixture-balance', customerId: 'fixture-customer', overseasWarehouseId: 'fixture-warehouse', inboundOrderId: 'fixture-inbound', inboundCode: 'IN-FIXTURE', palletId: 'fixture-available-pallet', palletNo: 'DP-FIXTURE-0018', warehouseLocationCode: 'LOC-FIXTURE', totalPackages: '3', availablePackages: '3', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', status: 'OCCUPIED' },
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listWarehouseTransferSources } from './warehouseTransferSources'
|
||||
import type { WarehouseTransferDetailDto } from './warehouseTransfers'
|
||||
import { getWarehouseTransfer, type WarehouseTransferDetailDto } from './warehouseTransfers'
|
||||
import { transferSourceWireFixture } from './warehouseTransferSources.fixture'
|
||||
|
||||
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: { get: vi.fn() } }))
|
||||
const transfer: WarehouseTransferDetailDto = { id: 'transfer', transferNo: 'WT-1', operatingWarehouseId: 'WH-1', needRelabel: true, packageCount: 1, status: 'APPROVED', items: [], cartons: [], pallets: [], relabelBindings: [{ operationType: 'SKU', customerCartonMark: 'BOX', originalSku: 'OLD', newSku: 'NEW', quantity: 1 }], sourceOrders: [{ lastlegOrderId: 'tail', inboundOrderId: 'IN-ID', inboundCode: 'IN-1' }] }
|
||||
@@ -11,6 +12,17 @@ const envelope = (data: unknown) => ({ code: '200', data })
|
||||
const page = (records: unknown[], current = 1, total = records.length) => envelope({ records, page: current, size: 200, total, pages: Math.ceil(total / 200) })
|
||||
describe('confirmed warehouse transfer source reads', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
it('normalizes the real original-ticket string quantities before source eligibility and package totals', async () => {
|
||||
const fixture = transferSourceWireFixture
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(fixture.transfer)).mockResolvedValueOnce(page([{ id: fixture.balance.id, skuCode: fixture.balance.skuCode }])).mockResolvedValueOnce(envelope(fixture.balance)).mockResolvedValueOnce(envelope(fixture.positions))
|
||||
const detail = await getWarehouseTransfer(fixture.customerId, fixture.transfer.transferNo)
|
||||
const result = await listWarehouseTransferSources(fixture.customerId, detail)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.candidates[0]).toMatchObject({ availablePackages: 0, canSelect: false })
|
||||
expect(result.candidates[1]).toMatchObject({ availablePackages: 3, canSelect: true })
|
||||
expect(detail.packageCount).toBe(1)
|
||||
expect(typeof detail.packageCount).toBe('number')
|
||||
})
|
||||
it('reads representative detail and actual positions with customer scope, accepts OCCUPIED available stock at original ABE2', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(page([{ id: 'B-1', skuCode: 'OLD' }])).mockResolvedValueOnce(envelope(balance)).mockResolvedValueOnce(envelope([position, { ...position, id: 'P-HOLD', availablePackages: 0, holdPackages: 1 }]))
|
||||
const result = await listWarehouseTransferSources('C-1', transfer)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s
|
||||
import type { InventoryBalanceDto, InventoryPositionDto } from './inventory'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload, type OverseasPageResult } from './lastMileOrders'
|
||||
import type { WarehouseTransferDetailDto } from './warehouseTransfers'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
export interface WarehouseTransferSourceCandidate {
|
||||
inventoryPositionId: string
|
||||
@@ -15,7 +16,7 @@ export interface WarehouseTransferSourceCandidate {
|
||||
canSelect: boolean
|
||||
blockReason: string
|
||||
}
|
||||
type ScopedPosition = InventoryPositionDto & { customerId: string }
|
||||
type ScopedPosition = Omit<InventoryPositionDto, 'availablePackages'> & { customerId: string; availablePackages: number | string }
|
||||
export async function listWarehouseTransferSources(customerId: string, transfer: WarehouseTransferDetailDto) {
|
||||
const sources = new Map((transfer.sourceOrders || []).map(row => [row.inboundOrderId, row]))
|
||||
if (!customerId || !transfer.operatingWarehouseId || !sources.size || [...sources.values()].some(row => !row.inboundOrderId || !row.inboundCode)) {
|
||||
@@ -51,10 +52,11 @@ export async function listWarehouseTransferSources(customerId: string, transfer:
|
||||
continue
|
||||
}
|
||||
const sku = balance.skuCode?.trim() || ''
|
||||
const availablePackages = normalizeWarehouseTransferQuantity(position.availablePackages, '库存位置可分配箱数')
|
||||
const reason = !position.palletId || !position.palletNo ? '缺少完整托盘业务码,待核查'
|
||||
: !Number.isSafeInteger(position.availablePackages) || position.availablePackages <= 0 ? '当前无可分配箱数'
|
||||
: 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 })
|
||||
candidates.set(position.id, { inventoryPositionId: position.id, palletId: position.palletId || '', inboundCode: source.inboundCode || '', skuCode: sku === 'MIXED' ? '—' : sku || '—', productNameZh: balance.productNameZh || '—', palletNo: position.palletNo || '—', warehouseLocationCode: position.warehouseLocationCode || '—', availablePackages, canSelect: !reason, blockReason: reason })
|
||||
}
|
||||
}
|
||||
currentPage++
|
||||
|
||||
@@ -7,8 +7,21 @@ vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOrigin
|
||||
describe('warehouseTransfers API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('normalizes detail, review and allocation box counts at the API boundary', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: '1' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: '1' } })
|
||||
expect((await getWarehouseTransfer('customer-1', 'WT-1')).packageCount).toBe(1)
|
||||
expect((await reviewWarehouseTransfer('customer-1', 'WT-1', 'APPROVE')).packageCount).toBe(1)
|
||||
expect((await allocateWarehouseTransfer('customer-1', 'WT-1', { idempotencyKey: 'fixed', allocations: [{ inventoryPositionId: 'position', palletId: 'pallet', packageCount: 1 }] })).packageCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects an ambiguous transfer total instead of passing it into strict selected-package comparison', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: true } })
|
||||
await expect(getWarehouseTransfer('customer-1', 'WT-1')).rejects.toThrow('本票转运箱数返回格式无效')
|
||||
})
|
||||
|
||||
it('preserves the body idempotency key, exact source positions and selected customer on replay', async () => {
|
||||
const value = { transferNo: 'WT-1', status: 'WAIT_RELABEL', relabelNo: 'RL-1', items: [], cartons: [], relabelBindings: [] }
|
||||
const value = { transferNo: 'WT-1', packageCount: 2, status: 'WAIT_RELABEL', relabelNo: 'RL-1', items: [], cartons: [], relabelBindings: [] }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: value })
|
||||
const payload = { idempotencyKey: 'allocation-fixed', allocations: [{ inventoryPositionId: '2100000000000000001', palletId: '2100000000000000002', packageCount: 2 }] }
|
||||
expect(await allocateWarehouseTransfer('20001', 'WT-1', payload)).toEqual(value)
|
||||
@@ -25,7 +38,7 @@ describe('warehouseTransfers API', () => {
|
||||
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' } })
|
||||
vi.mocked(http.post).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', packageCount: 3, status: 'WAIT_RELABEL', relabelNo: 'RL-SAME' } })
|
||||
await expect(allocateWarehouseTransfer('customer-1', 'WT-1', payload)).rejects.toThrow('response lost')
|
||||
expect(payload).toEqual(before)
|
||||
expect(await allocateWarehouseTransfer('customer-1', 'WT-1', payload)).toMatchObject({ status: 'WAIT_RELABEL', relabelNo: 'RL-SAME' })
|
||||
@@ -35,7 +48,7 @@ describe('warehouseTransfers API', () => {
|
||||
|
||||
it('always scopes platform list and detail requests to the selected customer', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 10, total: 0, pages: 0 } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', packageCount: 2, items: [], cartons: [], relabelBindings: [] } })
|
||||
await listWarehouseTransfers('20001', { page: 1, size: 10, status: 'PENDING_REVIEW' })
|
||||
await getWarehouseTransfer('20001', 'WT-1')
|
||||
expect(vi.mocked(http.get).mock.calls[0]).toEqual(['/platform/v1/last-leg/warehouse-transfers', { params: { page: 1, size: 10, status: 'PENDING_REVIEW', customerId: '20001' } }])
|
||||
@@ -43,7 +56,7 @@ describe('warehouseTransfers API', () => {
|
||||
})
|
||||
|
||||
it('posts the real review decision with customer scope', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', packageCount: 2, items: [], cartons: [], relabelBindings: [] } })
|
||||
await reviewWarehouseTransfer('20001', 'WT-1', 'APPROVE', '资料无误')
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/last-leg/warehouse-transfers/WT-1/review',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
export interface WarehouseTransferQuery {
|
||||
page: number
|
||||
@@ -93,14 +94,19 @@ export interface AllocateWarehouseTransferPayload {
|
||||
allocations: Array<{ inventoryPositionId: string; palletId: string; packageCount: number }>
|
||||
}
|
||||
|
||||
type WarehouseTransferDetailWire = Omit<WarehouseTransferDetailDto, 'packageCount'> & { packageCount: number | string }
|
||||
function normalizeTransferDetail(value: WarehouseTransferDetailWire): WarehouseTransferDetailDto {
|
||||
return { ...value, packageCount: normalizeWarehouseTransferQuantity(value.packageCount, '本票转运箱数') }
|
||||
}
|
||||
|
||||
/** The caller must use confirmed source positions, never aggregate inventory rows. */
|
||||
export async function allocateWarehouseTransfer(customerId: string, transferNo: string, payload: AllocateWarehouseTransferPayload) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/allocations`,
|
||||
payload,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) {
|
||||
@@ -112,11 +118,11 @@ export async function listWarehouseTransfers(customerId: string, query: Warehous
|
||||
}
|
||||
|
||||
export async function getWarehouseTransfer(customerId: string, transferNo: string) {
|
||||
const response = await http.get<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
const response = await http.get<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function reviewWarehouseTransfer(
|
||||
@@ -125,10 +131,10 @@ export async function reviewWarehouseTransfer(
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
remark?: string,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
const response = await http.post<ApiEnvelope<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/review`,
|
||||
{ decision, remark },
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user