feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user