fix(dispatch): align dispatch actions and native return workflows
This commit is contained in:
@@ -45,13 +45,33 @@ describe('delivery order API', () => {
|
||||
})
|
||||
|
||||
it('loads return options and marks a return in the explicit customer scope', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
|
||||
await listDeliveryReturnInboundOptions('DEL/1', '1001')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } })
|
||||
|
||||
const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
|
||||
const payload = { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } })
|
||||
await markDeliveryReturn('DEL/1', '1001', payload)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
|
||||
})
|
||||
|
||||
it('preserves original return carton identity and submits it without inventing an inventory position', async () => {
|
||||
const source = { outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-CARTON-1', maxReturnPackages: 1 }
|
||||
const relation = { lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '1001', returnSources: [{ ...source, maxReturnPackages: '1' }] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [relation] })
|
||||
const options = await listDeliveryReturnInboundOptions('DEL/1', '1001')
|
||||
expect(options[0]?.returnSources[0]).toEqual(source)
|
||||
expect(options[0]).toMatchObject({ lastMileOrderId: relation.lastMileOrderId, inboundOrderId: null, inboundCode: null })
|
||||
const payload = { lastMileOrderId: relation.lastMileOrderId, reason: '原箱拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: source.outboundPalletItemId, returnCartonId: source.returnCartonId, packageCount: 1 }], idempotencyKey: 'carton-return' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX-1' } })
|
||||
await markDeliveryReturn('DEL/1', '1001', payload)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } })
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundOrderId')
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', '0', 'invalid'])('rejects a candidate with invalid master identity %s even if it has an inbound association', async lastMileOrderId => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ lastMileOrderId, inboundOrderId: '9', inboundCode: 'IN-1', returnSources: [] }] })
|
||||
await expect(listDeliveryReturnInboundOptions('DEL/1', '1001')).rejects.toThrow('尾程主单标识')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import type { DeliveryDocumentSummary } from './deliveryDocuments'
|
||||
@@ -70,26 +71,27 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str
|
||||
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[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
|
||||
|
||||
export interface DeliveryReturnSource {
|
||||
export interface DeliveryReturnSource extends ReturnSourceIdentity {
|
||||
outboundPalletItemId: string
|
||||
inventoryPositionId: string
|
||||
cartonMark?: string | null
|
||||
palletNo?: string | null
|
||||
skuCode?: string | null
|
||||
maxReturnPackages: number
|
||||
}
|
||||
export interface DeliveryReturnRelation {
|
||||
inboundOrderId: string
|
||||
lastMileOrderId: string
|
||||
inboundOrderId?: string | null
|
||||
inboundCode?: string | null
|
||||
lastMileOrderNo?: string | null
|
||||
customerId: string
|
||||
returnSources: DeliveryReturnSource[]
|
||||
}
|
||||
export interface MarkDeliveryReturnPayload {
|
||||
inboundOrderId: string
|
||||
lastMileOrderId: string
|
||||
reason: string
|
||||
returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER'
|
||||
returnLogisticsNo?: string
|
||||
items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }>
|
||||
items: Array<ReturnSourceIdentity & { outboundPalletItemId: string; packageCount: number }>
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
@@ -120,7 +122,14 @@ export async function listDeliveryReturnInboundOptions(deliveryNo: string, custo
|
||||
const response = await http.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
|
||||
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
return unwrapApiResponse(response).map(relation => {
|
||||
if (typeof relation.lastMileOrderId !== 'string' || !/^[1-9]\d*$/.test(relation.lastMileOrderId)) throw new Error('退回候选缺少有效尾程主单标识,请重新加载')
|
||||
return { ...relation, returnSources: relation.returnSources.map(source => {
|
||||
const quantity: unknown = source.maxReturnPackages
|
||||
if ((typeof quantity !== 'number' && typeof quantity !== 'string') || !/^\d+$/.test(String(quantity)) || !Number.isSafeInteger(Number(quantity))) throw new Error('可退箱数无效,请重新加载')
|
||||
return { ...normalizeReturnSource(source), maxReturnPackages: Number(quantity) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
|
||||
export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) {
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface DispatchQuery {
|
||||
|
||||
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
|
||||
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
|
||||
export interface DispatchActions {
|
||||
canEdit: boolean
|
||||
editBlockReason: string | null
|
||||
canCancel: boolean
|
||||
cancelBlockReason: string | null
|
||||
canRemovePallet: boolean
|
||||
removePalletBlockReason: string | null
|
||||
canTransfer: boolean
|
||||
transferBlockReason: string | null
|
||||
outboundNo: string | null
|
||||
outboundStatus: string | null
|
||||
}
|
||||
|
||||
export interface DispatchPalletCandidateDto {
|
||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||
@@ -99,6 +111,7 @@ export async function getBatchDispatchCandidates(batchKey: string) {
|
||||
|
||||
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
|
||||
export interface DispatchSummaryDto {
|
||||
actions?: DispatchActions
|
||||
appointmentBatches: DispatchBatchReference[]
|
||||
workflow?: DispatchWorkflow | null
|
||||
id: string
|
||||
|
||||
@@ -5,6 +5,13 @@ import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, ge
|
||||
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('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
|
||||
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
|
||||
for (const identity of [{ inventoryPositionId: null, returnCartonId: null }, { inventoryPositionId: '20', returnCartonId: '21' }, { inventoryPositionId: 'null', returnCartonId: null }, { inventoryPositionId: null, returnCartonId: Number.MAX_SAFE_INTEGER + 2 }]) {
|
||||
expect(() => normalizeExceptionDetail({ ...raw, returnSources: [{ ...raw.returnSources[0]!, ...identity }] } as ExceptionDetail)).toThrow()
|
||||
}
|
||||
})
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeReturnSource, type ReturnSourceIdentity } from '../returnSource'
|
||||
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
@@ -13,12 +14,12 @@ export interface ExceptionOrderRow {
|
||||
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 ExceptionReturnSource extends ReturnSourceIdentity { outboundPalletItemId: string; palletNo?: string | null; skuCode?: string | null; cartonMark?: 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 ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: 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 }
|
||||
@@ -29,7 +30,7 @@ function safeInteger(value: unknown, label: string) {
|
||||
}
|
||||
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, '剩余可收数量') })),
|
||||
returnSources: value.returnSources.map(row => ({ ...normalizeReturnSource(row), outboundPalletItemId: String(row.outboundPalletItemId), 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, '重派实拣数量') })) })) }
|
||||
|
||||
Reference in New Issue
Block a user