merge admin frontend changes
This commit is contained in:
@@ -23,6 +23,15 @@ describe('delivery order API', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } })
|
||||
})
|
||||
|
||||
it.each([null, 0, 3])('preserves actual nullable signed quantity %s in list and detail', async signedPackages => {
|
||||
const order = { deliveryNo: 'DEL-1', version: '0', packageCount: 9, signedPackages }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [order], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
const page = await listDeliveryOrders({ page: 1, size: 20 })
|
||||
expect(page.records[0]?.signedPackages).toBe(signedPackages)
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { order, skus: [], fees: [], operations: [] } })
|
||||
expect((await getDeliveryOrder('DEL-1', '1001')).order.signedPackages).toBe(signedPackages)
|
||||
})
|
||||
|
||||
it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } })
|
||||
const detail = await getDeliveryOrder('DEL/1', '1001')
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface DeliveryOrderRow {
|
||||
sellerName?: string | null
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
signedPackages: number | null
|
||||
totalWeightKg?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
plannedDeliveryAt?: string | null
|
||||
@@ -65,9 +66,9 @@ export interface DeliveryOrderRow {
|
||||
}
|
||||
|
||||
export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; productQuantity?: number | null; weightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
|
||||
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null; reviewStatus?: string | null; sourceStatus?: string | null; voidStatus?: string | null }
|
||||
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
|
||||
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
|
||||
|
||||
export interface DeliveryReturnSource {
|
||||
outboundPalletItemId: string
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
|
||||
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
describe('dispatch overseas API', () => {
|
||||
it('submits all explicit draft groups once and preserves container and source identities', async () => {
|
||||
const payload = { draftToken: 'original-draft', groups: [
|
||||
{ draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
|
||||
{ draftContainerId: '190000000000000100', planPalletIds: ['12'], returnReceiptIds: ['22'] },
|
||||
] }
|
||||
const result = payload.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: { id: String(index), dispatchNo: `DS-${index}` } }))
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: result })
|
||||
expect(await preArrangeDraftDispatch(payload)).toEqual(result)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload)
|
||||
})
|
||||
it('propagates a batch rejection without falling back to a flat or repeated command', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '草稿来源已变化', data: null })
|
||||
await expect(preArrangeDraftDispatch({ draftToken: 'original-draft', groups: [
|
||||
{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: [] },
|
||||
] })).rejects.toThrow('草稿来源已变化')
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('locks and submits actual return receipts without pretending they are plan pallets', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: null })
|
||||
await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1')
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', {
|
||||
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1',
|
||||
})
|
||||
await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return')
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', {
|
||||
sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return',
|
||||
})
|
||||
await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' })
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', {
|
||||
planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return',
|
||||
})
|
||||
})
|
||||
it('reads external freight from the scoped dispatch without a supplied customer', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
|
||||
expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
|
||||
})
|
||||
it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
|
||||
const result = await getDispatchFeeCoverage('DP/1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
|
||||
expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
|
||||
@@ -9,9 +9,16 @@ export interface DispatchQuery {
|
||||
vehiclePlate?: string
|
||||
status?: string
|
||||
fbaAppointmentNo?: string
|
||||
stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
|
||||
}
|
||||
|
||||
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
|
||||
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
|
||||
|
||||
export interface DispatchPalletCandidateDto {
|
||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||
returnReceiptId?: string | null
|
||||
returnReceiptNo?: string | null
|
||||
planId?: string | null
|
||||
planPalletId?: string | null
|
||||
warehousePalletId?: string | null
|
||||
@@ -60,6 +67,7 @@ export interface DispatchDraftDto { draftToken: string; containers?: DispatchDra
|
||||
|
||||
export interface PreArrangeDispatchPayload {
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds?: string[]
|
||||
fbaAppointmentNo?: string
|
||||
fbaAppointmentStatus?: string
|
||||
vehicleAppointmentStatus?: string
|
||||
@@ -67,7 +75,22 @@ export interface PreArrangeDispatchPayload {
|
||||
relabelNo?: string
|
||||
}
|
||||
|
||||
export interface PreArrangeDispatchDraftGroup {
|
||||
draftContainerId: string
|
||||
planPalletIds: string[]
|
||||
returnReceiptIds: string[]
|
||||
}
|
||||
export interface PreArrangeDispatchDraftPayload {
|
||||
draftToken: string
|
||||
groups: PreArrangeDispatchDraftGroup[]
|
||||
}
|
||||
export interface PreArrangeDispatchDraftResult {
|
||||
draftContainerId: string
|
||||
dispatch: DispatchSummaryDto
|
||||
}
|
||||
|
||||
export interface DispatchSummaryDto {
|
||||
workflow?: DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
vehiclePlate?: string | null
|
||||
@@ -198,9 +221,21 @@ export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function lockDispatchDraftPallet(planPalletId: string, draftToken: string, draftContainerId?: string) {
|
||||
export async function preArrangeDraftDispatch(payload: PreArrangeDispatchDraftPayload) {
|
||||
const response = await http.post<ApiEnvelope<PreArrangeDispatchDraftResult[]>, ApiEnvelope<PreArrangeDispatchDraftResult[]>>(
|
||||
'/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
function draftSource(selection: string) {
|
||||
return selection.startsWith('RETURN:')
|
||||
? { sourceType: 'RETURN_RECEIPT', returnReceiptId: selection.slice(7) }
|
||||
: { planPalletId: selection }
|
||||
}
|
||||
export async function lockDispatchDraftPallet(selection: string, draftToken: string, draftContainerId?: string) {
|
||||
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId, draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -218,9 +253,9 @@ export async function deleteDispatchDraftContainer(draftToken: string, container
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function unlockDispatchDraftPallet(planPalletId: string, draftToken: string) {
|
||||
export async function unlockDispatchDraftPallet(selection: string, draftToken: string) {
|
||||
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId, draftToken },
|
||||
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -235,6 +270,9 @@ export async function releaseDispatchDraft(draftToken: string) {
|
||||
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
|
||||
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
|
||||
export interface TransferDeliveryPayload {
|
||||
feeCoverageCargoHash?: string
|
||||
feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
|
||||
externalFreightHash?: string
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
deliveryMethod: string
|
||||
@@ -262,3 +300,24 @@ export async function transferDispatchToDelivery(dispatchNo: string, payload: Tr
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface ExternalFreightPreview {
|
||||
snapshotHash: string
|
||||
requiresDeliveryFee: boolean
|
||||
quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
|
||||
customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
|
||||
}
|
||||
export async function getDispatchExternalFreight(dispatchNo: string): Promise<ExternalFreightPreview> {
|
||||
const response = await http.get<ApiEnvelope<ExternalFreightPreview>, ApiEnvelope<ExternalFreightPreview>>(
|
||||
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getDispatchFeeCoverage(dispatchNo: string): Promise<import('../feeCoverageTypes').FeeCoveragePreview> {
|
||||
const response = await http.get<ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>, ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>>(
|
||||
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
|
||||
)
|
||||
const data = unwrapApiResponse(response)
|
||||
return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from './exceptionOrders'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('exception order API', () => {
|
||||
it('preserves receipt and dispatch business numbers while normalizing all actual history quantities', () => {
|
||||
const raw = { order: { version: '2' }, returnSources: [], returnReceipts: [{ receiptNo: 'RSR-9007199254740993', receivedPackages: '2', receivedAt: '2026-10-03T23:24:13',
|
||||
redispatches: [{ dispatchNo: 'DS/1', status: 'DEPARTED', selectedPackages: '2', pickedPackages: '2', deliveries: [{ deliveryNo: 'DL/1', status: 'DELIVERED' }] }] }] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.returnReceipts[0]).toMatchObject({ receiptNo: 'RSR-9007199254740993', receivedPackages: 2,
|
||||
redispatches: [{ dispatchNo: 'DS/1', selectedPackages: 2, pickedPackages: 2 }] })
|
||||
raw.returnReceipts[0]!.receivedPackages = null as unknown as number
|
||||
expect(() => normalizeExceptionDetail(raw)).toThrow('历史实收数量无效')
|
||||
})
|
||||
it('normalizes actual string versions and quantities without converting business references to numbers', () => {
|
||||
const raw = { order: { version: '7', evidenceFileIds: ['190000000000000099'] }, returnSources: [{ outboundPalletItemId: '190000000000000098', inventoryPositionId: '190000000000000097', maxReturnPackages: '2' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.order.version).toBe(7); expect(result.returnSources[0]?.maxReturnPackages).toBe(2)
|
||||
expect(result.returnSources[0]?.outboundPalletItemId).toBe('190000000000000098')
|
||||
for (const version of ['', '-1', '1.5', '9007199254740993', undefined]) expect(() => normalizeExceptionDetail({ ...raw, order: { ...raw.order, version } } as unknown as ExceptionDetail)).toThrow()
|
||||
})
|
||||
it('loads authoritative detail and scopes every versioned command and evidence upload to its customer and business number', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version: '7', allowedActions: ['COMPLETE_EXCEPTION'] }, returnSources: [], returnReceipts: [] } })
|
||||
await getExceptionDetail('100', 'EX/1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
|
||||
for (const action of ['start-processing', 'cancel', 'complete'] as const) {
|
||||
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
|
||||
await commandException('100', 'EX/1', action, body)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
|
||||
}
|
||||
const file = new File(['evidence'], 'receipt.txt')
|
||||
await uploadExceptionEvidence('100', 'EX/1', file)
|
||||
const call = vi.mocked(http.post).mock.calls.at(-1)!
|
||||
expect(call[0]).toBe('/platform/v1/last-leg/exception-orders/EX%2F1/evidence')
|
||||
expect((call[1] as FormData).get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '100' } })
|
||||
})
|
||||
beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() })
|
||||
|
||||
it('queries the real paginated exception endpoint', async () => {
|
||||
|
||||
@@ -10,6 +10,43 @@ export interface ExceptionOrderRow {
|
||||
deliveryOrderId?: string | null; deliveryNo?: string | null; outboundNo?: string | null
|
||||
skuCode?: string | null; packageCount?: number | null; returnDirection?: string | null; returnLogisticsNo?: string | null
|
||||
handlingAction?: string | null; handlerName?: string | null; exceptionAt: string; updatedAt: string
|
||||
version?: number; allowedActions?: string[]; evidenceFileIds?: string[]; handlingResult?: string | null; completedAt?: string | null
|
||||
}
|
||||
|
||||
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
|
||||
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
|
||||
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
|
||||
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] }
|
||||
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
|
||||
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
|
||||
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
|
||||
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
|
||||
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
|
||||
function safeInteger(value: unknown, label: string) {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${label}无效,请重新加载`)
|
||||
return Number(value)
|
||||
}
|
||||
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
|
||||
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
|
||||
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
|
||||
returnReceipts: value.returnReceipts.map(receipt => ({ ...receipt, receivedPackages: safeInteger(receipt.receivedPackages, '历史实收数量'),
|
||||
redispatches: receipt.redispatches.map(dispatch => ({ ...dispatch, selectedPackages: safeInteger(dispatch.selectedPackages, '重派选定数量'),
|
||||
pickedPackages: safeInteger(dispatch.pickedPackages, '重派实拣数量') })) })) }
|
||||
}
|
||||
export async function getExceptionDetail(customerId: string, exceptionNo: string) {
|
||||
const response = await http.get<ApiEnvelope<ExceptionDetail>, ApiEnvelope<ExceptionDetail>>(exceptionPath(exceptionNo), { params: { customerId } })
|
||||
return normalizeExceptionDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function commandException(customerId: string, exceptionNo: string, action: ExceptionAction, payload: ExceptionCommand) {
|
||||
const response = await http.post<ApiEnvelope<ExceptionCommandResult>, ApiEnvelope<ExceptionCommandResult>>(`${exceptionPath(exceptionNo)}/${action}`, payload, { params: { customerId } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, version: safeInteger(result.version, '操作版本'), restoredPackages: safeInteger(result.restoredPackages, '实际入账数量') }
|
||||
}
|
||||
export async function uploadExceptionEvidence(customerId: string, exceptionNo: string, file: File) {
|
||||
const form = new FormData(); form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<{ fileId: string; fileName: string }>, ApiEnvelope<{ fileId: string; fileName: string }>>(`${exceptionPath(exceptionNo)}/evidence`, form, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export interface ExceptionRelation {
|
||||
lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { reviewCargoDiscard } from './lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('last-mile order overseas API', () => {
|
||||
it('reviews a discard by business number with an explicit string customer query', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { status: 'REJECTED' } })
|
||||
await expect(reviewCargoDiscard('CD/TEST-1', '2092618717861048321', 'REJECT', ' 箱数不符 ')).resolves.toEqual({ status: 'REJECTED' })
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/cargo-discards/CD%2FTEST-1/review',
|
||||
{ decision: 'REJECT', remark: '箱数不符' }, { params: { customerId: '2092618717861048321' } })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface LastMileOrderQuery {
|
||||
orderNo?: string
|
||||
relabelNo?: string
|
||||
orderType?: string
|
||||
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
|
||||
inboundCode?: string
|
||||
lxCode?: string
|
||||
containerNo?: string
|
||||
@@ -265,6 +266,7 @@ export interface ContainerInboundPlanPreviewGroupDto {
|
||||
}
|
||||
|
||||
export interface ContainerInboundPlanPreviewDto {
|
||||
resourceRetry?: { idempotencyKey: string; snapshotVersion: number; snapshotHash: string } | null
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
overseasWarehouseId: string | null
|
||||
@@ -365,6 +367,15 @@ export async function getLastMileOrder(orderId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewCargoDiscard(discardNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
|
||||
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`,
|
||||
{ decision, remark: remark?.trim() || undefined },
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
|
||||
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getOverseasLocation } from './locations'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: { get: vi.fn() } }))
|
||||
it('reads one location through its warehouse-scoped detail endpoint without scanning pages', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '40', warehouseId: '92', locationCode: 'LOC' } })
|
||||
expect(await getOverseasLocation('92', '40')).toEqual({ id: '40', warehouseId: '92', locationCode: 'LOC' })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/overseas-warehouse-resources/92/locations/40')
|
||||
})
|
||||
@@ -35,6 +35,11 @@ export interface OverseasLocationQuery { page?: number; size?: number; keyword?:
|
||||
|
||||
function base(warehouseId: string) { return `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations` }
|
||||
|
||||
export async function getOverseasLocation(warehouseId: string, locationId: string) {
|
||||
const response = await http.get<ApiEnvelope<OverseasLocationDto>, ApiEnvelope<OverseasLocationDto>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listWarehouseTransferSources } from './warehouseTransferSources'
|
||||
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' }] }
|
||||
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('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)
|
||||
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,66 @@
|
||||
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'
|
||||
import { normalizeWarehouseTransferQuantity } from './warehouseTransferQuantities'
|
||||
|
||||
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 = 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)) {
|
||||
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 availablePackages = normalizeWarehouseTransferQuantity(position.availablePackages, '库存位置可分配箱数')
|
||||
const reason = !position.palletId || !position.palletNo ? '缺少完整托盘业务码,待核查'
|
||||
: 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, canSelect: !reason, blockReason: reason })
|
||||
}
|
||||
}
|
||||
currentPage++
|
||||
} while (recordsRead < total)
|
||||
}
|
||||
return { candidates: [...candidates.values()], issues }
|
||||
}
|
||||
@@ -1,15 +1,70 @@
|
||||
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, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses } 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('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' } })
|
||||
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', 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)
|
||||
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', 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' })
|
||||
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: [] } })
|
||||
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' } }])
|
||||
@@ -17,7 +72,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,8 @@
|
||||
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
|
||||
@@ -84,6 +86,91 @@ 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 {
|
||||
idempotencyKey: string
|
||||
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<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/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<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/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<OverseasPagePayload<ReceivingOverseasWarehouseDto>>, ApiEnvelope<OverseasPagePayload<ReceivingOverseasWarehouseDto>>>(
|
||||
'/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<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<WarehouseTransferDetailWire>, ApiEnvelope<WarehouseTransferDetailWire>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/allocations`,
|
||||
payload,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return normalizeTransferDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) {
|
||||
@@ -95,11 +182,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(
|
||||
@@ -108,10 +195,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