feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution

This commit is contained in:
2026-10-19 23:40:42 +08:00
parent e0677e7ae7
commit 9dd34791f7
346 changed files with 29245 additions and 12602 deletions
@@ -6,23 +6,34 @@ import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-hand
const copy = value => value == null ? value : structuredClone(value)
function fixture(fault = '') {
const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString()
const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92',
const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', dispatchNo: 'PC-ONE', vehicleId: '11', driverId: '21', warehouseId: '92',
stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] }
const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' }
const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT',
recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
const seeds = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
if (fault === 'r5-layout') {
Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [
Object.assign(seeds[0], { packageCount: 5, pickedPackageCount: 5, items: [
{ id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] })
Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] })
Object.assign(seeds[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] })
}
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount }))
const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo,
const cargo = seeds.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: 'PC-ONE',
dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount,
destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' })))
const totalPackages = cargo.reduce((n, row) => n + row.packages, 0)
const counts = { itemCount: cargo.length, palletCount: new Set(cargo.map(r => r.warehousePalletId)).size,
packageCount: totalPackages, pickedPackageCount: totalPackages, shippedPackageCount: totalPackages }
const nativeItems = cargo.map(row => ({ dispatchItemId: row.dispatchItemId, warehousePalletId: row.warehousePalletId,
customerId: row.customerId, destination: { key: row.destinationKey }, scheduledPackages: row.packages, pickedPackages: row.packages }))
const nativeReferences = cargo.map((row,index) => ({ deliveryNo: `DL-${row.customerId}-${index}`, customerId: row.customerId, packageCount: row.packages }))
const liveDispatches = [{ dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', counts,
documents: { deliveries: nativeReferences, remark: marker } }]
const dispatchDetails = copy(liveDispatches)
const pdaTask = { dispatchNo: 'PC-ONE', taskNo: 'PC-ONE', warehouseId: '92', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19',
counts, pickingReconciled: true, items: nativeItems.map(row => ({ dispatchItemId: row.dispatchItemId, customerId: row.customerId,
scheduledPackages: row.scheduledPackages, pickedPackages: row.pickedPackages })) }
const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 }))
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
pallets } } }
@@ -34,13 +45,13 @@ function fixture(fault = '') {
const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId }))
if (fault === 'binding-customer') bindings[0].customerId = 'OTHER'
if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }]
const bindings = [{ dispatchNo: 'PC-ONE' }]
if (fault === 'binding-customer') nativeItems[0].customerId = 'OTHER'
if (fault === 'dispatch-cargo') nativeItems[0].dispatchItemId = 'OTHER-ITEM'
const context = {
context: { async checkpointFleetHandover() {} },
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } },
departure: { dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: now, shippedPackages: totalPackages } } } },
assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) },
async requestMultipart(method, path, file) {
calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0)
@@ -55,14 +66,53 @@ function fixture(fault = '') {
}
if (path.includes('/handover-status')) {
assert.equal(token, 'picker-secret')
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
const dispatchNo = path.split('/').at(-2)
const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport
return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport,
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null,
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${row.customerId}-${index}`, status: 'DELIVERING', packages: row.packages, terminalAt: null,
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })),
latestHandover: handover ? { handoverNo: handover.handoverNo } : null })
}
if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1)))
if (path.startsWith('/api/pda/v1/last-leg/dispatches/') && !path.endsWith('/handover-status')) {
assert.equal(token, 'picker-secret')
const task = copy(pdaTask)
if (fault === 'pda-task-drift' && handover) task.counts.pickedPackageCount--
return ok(task)
}
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, [])
assert.equal(body.vehicle, null); assert.equal(body.driver, null)
if (fault === 'unknown-probe-create') throw new Error('native empty dispatch result unknown')
const index = probes.size + 1
const next = { tripNo: `PROBE-${index}`, dispatchNo: `EMPTY-${index}`, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0',
counts: { itemCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 }, documents: { remark: body.remark },
warehouseId: body.warehouseId }
probes.set(next.tripNo, next)
const { tripNo: internalOnly, ...response } = next
return ok(response)
}
if (path.startsWith('/api/platform/v1/last-leg/dispatches/EMPTY-')) {
const number = path.match(/dispatches\/(EMPTY-\d+)/)[1], probe = [...probes.values()].find(i => i.dispatchNo === number)
if (path.endsWith('/items')) return ok({ dispatchNo: probe.dispatchNo, recordVersion: probe.recordVersion, items: fault === 'probe-has-cargo' ? [{}] : [] })
if (path.endsWith('/cancel')) {
assert.equal(body.recordVersion, probe.recordVersion)
for (const [resource, owner] of resources) if (owner === probe.tripNo) resources.delete(resource)
probe.status = 'CANCELLED'; return ok(probe)
}
if (method === 'PUT') {
assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.driver.mode, 'MASTER'); assert.ok(body.idempotencyKey)
if (resources.has(`VEHICLE:${body.vehicle.vehicleId}`)) return conflict('车辆已被车次占用')
if (resources.has(`DRIVER:${body.driver.driverId}`)) return conflict('司机已被车次占用')
resources.set(`VEHICLE:${body.vehicle.vehicleId}`, probe.tripNo); resources.set(`DRIVER:${body.driver.driverId}`, probe.tripNo)
Object.assign(probe, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId, vehiclePlate: originalTransport.vehiclePlate, recordVersion: '1' })
}
const current = copy(probe)
if (fault === 'probe-version-drift') current.recordVersion = '99'
if (fault === 'probe-has-scan') current.counts.pickedPackageCount = 1
return ok(current)
}
if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE/items') return ok({ dispatchNo: 'PC-ONE', recordVersion: '19', items: nativeItems })
if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE') return ok(liveDispatches[0])
if (path.endsWith('/master-data/vehicles') && method === 'POST') {
assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate')
const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle)
@@ -86,22 +136,13 @@ function fixture(fault = '') {
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver)
}
if (path.endsWith('/fleet/trips') && method === 'POST') {
const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] })
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) {
const rows = [...probes.values()].filter(i => i.vehicleId).map(i => ({ ...i, status: 'PLANNED' }))
return ok({ records: rows, total: rows.length, page: 1, size: 200 })
}
if (path.includes('/trips/PROBE-')) {
const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number)
if (path.endsWith('/assignment')) {
if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用')
if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用')
resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number)
Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' })
}
if (path.endsWith('/cancel')) {
for (const [resource, owner] of resources) if (owner === number) resources.delete(resource)
probe.status = 'CANCELLED'
}
return ok({ trip: probe, dispatches: [] })
return ok({ trip: { ...probe, status: 'PLANNED' }, dispatches: [{ dispatchNo: probe.dispatchNo }] })
}
if (path.includes('/history?')) {
const departure = copy(originalHistory)
@@ -159,9 +200,12 @@ test('fresh real departure carries all customer cargo, preserves DEPART and fees
assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/)
assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE')
assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64)
assert.equal(result.dispatchNos.length, 1); assert.equal(result.pdaTasksBefore.length, 1)
assert.equal(f.proof.marker, result.replacement.vehiclePlate, 'current unique plate follows the resources for later native closure probes')
assert.doesNotMatch(JSON.stringify(result), /picker-secret/)
assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed))
assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel')))
assert.ok(!f.calls.some(row => row.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(row.path || '')))
assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1')
})
@@ -238,7 +282,8 @@ for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接
'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束',
'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆',
'pda-task-drift': '交接不改变PDA原生整车版本及扫码归属', 'unknown-probe-create': 'native empty dispatch result unknown' })) {
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
const f = fixture(fault)
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message))
@@ -273,12 +318,22 @@ test('explicit closure cleanup cancels only test probes and disables newly creat
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
})
for (const fault of ['probe-has-cargo', 'probe-version-drift', 'probe-has-scan']) test(`native empty-dispatch cleanup refuses unexpected state: ${fault}`, async () => {
const f = fixture(fault)
await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() }
f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22')
const before = f.calls.length
await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅取消本轮未执行/)
assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET'))
})
test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => {
const f = fixture('driver-default-null')
assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED')
})
test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => {
test('r5 one cross-customer dispatch, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => {
const f = fixture('r5-layout')
const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3)