Files
admin-web/src/modules/overseas-warehouse/dispatchWire.test.ts
T

112 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { parseApiJsonResponse } from '@chongchong/shared'
import { candidate, command, detail, destination, loadingPreview, page } from './dispatchTestFixtures'
import * as wire from './dispatchWire'
// Exercise the same JSON parser as Axios; quoted Long values must reach the boundary intact.
const parsed = <T>(value: T): T => parseApiJsonResponse(JSON.stringify(value)) as T
const id = '9007199254740993'
const counts = { itemCount: '1', palletCount: '2', packageCount: '12', pickedPackageCount: '0', shippedPackageCount: '0' }
const gate = { allowed: true, blockCode: null, blockReason: null }
const source = () => ({ ...candidate(), customerId: id, availablePackages: '12' })
const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersion: id, warehouseId: id, status: 'ACTIVE', sources: [source()], containers: [{ draftContainerId: id, sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], expiresAt: '' })
const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => {
const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' }
const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin }
const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] }
const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!
expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24)
cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null }
expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin })
cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull()
cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' }
expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数')
})
describe('native dispatch Long quantity boundary', () => {
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
const value = { ...source(), appointmentBatches: [reference] }
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
const existing = detail()
expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
})
it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
expect(() => wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
})
it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
})
it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
})
it('accepts zero and the safe integer limit in numeric and string form', () => {
for (const value of [0, '0', Number.MAX_SAFE_INTEGER, String(Number.MAX_SAFE_INTEGER)]) expect(wire.dispatchQuantity(value, '箱数')).toBe(Number(value))
})
it('normalizes all list, statistics and review counts while preserving versions and IDs', () => {
const value = page()
const result = wire.normalizeDispatchPage(parsed({ ...value, total: '8', page: '1', size: '10', pages: '1', records: [{ ...value.records[0]!, warehouseId: id, recordVersion: id, counts, reviews: { ...value.records[0]!.reviews, dispatchReview: { ...value.records[0]!.reviews.dispatchReview, reviewRound: id } } }], statistics: { matchedTotal: '8', activeTotal: '7', cancelledTotal: '1', stageCounts: { BEFORE_LOADING: '3', LOADING: '1', AFTER_LOADING: '1', DELIVERING: '1', DELIVERED: '1' }, beforeLoadingReviewCounts: { vehicleInfoTotal: '3', dispatchReview: { notSubmitted: '1', pending: '1', approved: '1', rejected: '0' }, loadingReview: { notSubmitted: '2', pending: '0', approved: '1', rejected: '0' } } } }))
expect(result).toMatchObject({ total: 8, page: 1, size: 10, pages: 1, statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: value.statistics.stageCounts, beforeLoadingReviewCounts: value.statistics.beforeLoadingReviewCounts } })
expect(result.records[0]).toMatchObject({ warehouseId: id, recordVersion: id, counts: { itemCount: 1, palletCount: 2, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: { dispatchReview: { reviewRound: id } } })
})
it('normalizes detail document quantities without changing nullable Integer measurements or versions', () => {
const value = detail()
const result = wire.normalizeDispatchDetail(parsed({ ...value, recordVersion: id, counts, documents: { ...value.documents, ...references() }, orderDetails: [{ ...value.orderDetails[0]!, palletCount: null }] }))
expect(result.recordVersion).toBe(id); expect(result.counts.packageCount).toBe(12); expect(result.orderDetails[0]!.palletCount).toBeNull()
expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
})
it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
const candidates = wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], snapshotAt: '', complete: true }))
expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
const result = wire.normalizeDispatchDraft(parsed(draft()))
expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
})
it('normalizes actual item and loading quantities while keeping decimal measurements and integer positions', () => {
const value = source()
const items = wire.normalizeDispatchItems(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...value.selection, dispatchItemId: id, sourceKey: value.sourceKey, sourceVersion: value.sourceVersion, customerId: id, customerName: value.customerName, palletNo: value.palletNo, orderNo: value.orderNo, inboundCode: value.inboundCode, destination: value.destination, scheduledPackages: '12', pickedPackages: '0', remove: gate }] } satisfies wire.DispatchItemsWire))
expect(items.items[0]).toMatchObject({ dispatchItemId: id, scheduledPackages: 12, pickedPackages: 0 }); expect(items.recordVersion).toBe(id)
const preview = loadingPreview()
const loading = wire.normalizeLoadingPreview(parsed({ ...preview, recordVersion: id, pallets: [{ ...preview.pallets[0]!, warehousePalletId: id, scheduledPackages: '12', pickedPackages: '12', weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 }] }))
expect(loading.pallets[0]).toMatchObject({ warehousePalletId: id, scheduledPackages: 12, pickedPackages: 12, weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 })
})
it('normalizes file size before metadata and Blob validation without rounding file identity', () => {
const result = wire.normalizeEvidenceFile(parsed({ fileId: id, fileName: '证据.png', contentType: 'image/png', sizeBytes: '3' }))
expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
})
it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [], sourceOrigin: null }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
})
it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
const base = { ...command('EDIT'), counts, recordVersion: id }
const pick = wire.normalizeDispatchCommand(parsed({ ...base, action: 'PICK', result: { dispatchItemId: id, positionId: id, returnCartonId: null, packages: '2', pickedPackages: '12', pickingReconciled: true } } satisfies wire.CommandResultWire))
expect(pick).toMatchObject({ recordVersion: id, counts: { packageCount: 12 }, result: { dispatchItemId: id, packages: 2, pickedPackages: 12 } })
const depart: wire.CommandResultWire = { ...base, action: 'DEPART', result: { departedAt: '', shippedPackages: '12', ...references() } }
expect(wire.normalizeDispatchCommand(parsed(depart))).toMatchObject({ result: { shippedPackages: 12, outbounds: [{ packageCount: 12 }], deliveries: [{ palletCount: 2 }] } })
const arranged: wire.CommandResultWire = { ...base, action: 'ARRANGE_DELIVERY', result: arrangement() }
expect(wire.normalizeDispatchCommand(parsed(arranged))).toMatchObject({ result: { recordVersion: id, deliveries: [{ packageCount: 12 }] } })
expect(wire.normalizeConvertedDraft(parsed({ draftToken: 'draft', vehicles: [{ draftContainerId: id, dispatch: depart }], replayed: true }))).toMatchObject({ vehicles: [{ draftContainerId: id, dispatch: { recordVersion: id, counts: { pickedPackageCount: 0 }, result: { shippedPackages: 12 } } }] })
})
it('rejects malformed nested quantities at the boundary', () => {
expect(() => wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [{ ...source(), availablePackages: '9007199254740992' }] }))).toThrow('安全整数')
expect(() => wire.normalizeEvidenceFile({ fileId: id, fileName: '', contentType: '', sizeBytes: null } as unknown as wire.DispatchEvidenceFileWire)).toThrow('有效非负整数')
expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
})
})
it('normalizes paginated long totals while keeping whole-group and page completeness independent', () => {
const result = wire.normalizeCandidateList(parsed({ candidates: [source()], complete: false, snapshotAt: '', total: '41', pages: '3', page: 2, size: 20 }))
expect(result).toMatchObject({ total: 41, pages: 3, page: 2, size: 20, complete: false })
expect(result.candidates[0]!.selectionGroup.complete).toBe(true)
})
it.each([{ total: '9007199254740993' }, { pages: '9007199254740993' }, { total: undefined }, { size: 51 }, { page: 0 }, { pages: '2' }])('fails closed for invalid pagination metadata %j', overrides => {
expect(() => wire.normalizeCandidateList(parsed({ candidates: [], complete: false, snapshotAt: '', total: '21', pages: '3', page: 2, size: 10, ...overrides } as unknown as wire.CandidateListWire))).toThrow(/候选分页信息无效|排车响应/)
})