fix(fleet): reuse reviewed handover vehicle and check formal responses

This commit is contained in:
2026-10-03 21:00:07 +08:00
parent 99a469e6cf
commit 18b39b4378
5 changed files with 226 additions and 48 deletions
@@ -14,13 +14,23 @@ function fixture(fault = '') {
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 }))
const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' }))
if (fault === 'r5-layout') {
Object.assign(dispatchDetails[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 }] })
}
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,
dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount,
destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' })))
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: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } }
pallets } } }
const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']])
const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = []
let createdVehicles = 0, handover = null, preview = null
let createdVehicles = fault === 'reviewed-vehicle' ? 1 : 0, handover = null, preview = null
if (fault === 'reviewed-vehicle') vehicles.set('12', { id: '12', plateNumber: 'HO-24255909-3BC', recordVersion: '0',
status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 })
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) }
@@ -30,11 +40,12 @@ function fixture(fault = '') {
const context = {
context: { async checkpointFleetHandover() {} },
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: 20, dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: 10 })) } } },
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 })) } } },
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)
drivers.get('22').recordVersion = '1'; return ok({ id: '22', recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' })
Object.assign(drivers.get('22'), { recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' });
return ok(fault === 'upload-identity' ? { ...drivers.get('22'), id: 'OTHER' } : drivers.get('22'))
},
async request(method, path, body, token) {
calls.push({ method, path, body: copy(body), token })
@@ -47,12 +58,13 @@ function fixture(fault = '') {
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
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: [{ deliveryNo: `DL-${customer}`, status: 'WAIT_DELIVERY', packages: 10, terminalAt: null,
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } }],
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', 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.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)
}
if (path.includes('/master-data/vehicles/')) {
@@ -61,13 +73,18 @@ function fixture(fault = '') {
return ok(vehicle)
}
if (path.endsWith('/fleet/drivers') && method === 'POST') {
const driver = { id: '22', recordVersion: '0', details: body, active: true }; drivers.set(driver.id, driver); return ok(driver)
const profile = fault === 'driver-default-null' ? { warehouseId: null, vehicleModels: null, languagesAndArea: null,
medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } : {}
const driver = { id: '22', driverNo: 'DR-TEST', recordVersion: '0', details: { ...body, profile,
...(fault === 'driver-default-null' ? { licenseIssuedOn: null } : {}) }, active: true, reviewStatus: 'PENDING' }; drivers.set(driver.id, driver);
if (fault === 'driver-default-nonempty') driver.details.profile.warehouseId = 'OTHER'
return ok(fault === 'driver-identity' ? { ...driver, recordVersion: '3' } : driver)
}
if (path.includes('/fleet/drivers/')) {
const driver = drivers.get('22')
if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' }
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
return ok(driver)
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: [] })
@@ -93,9 +110,13 @@ function fixture(fault = '') {
return ok({ records, total: records.length })
}
if (path.endsWith('/handovers/preview')) {
preview = { cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: ['PALLET-INTERNAL'], occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 } } }
preview = { tripNo: trip.tripNo, recordVersion: trip.recordVersion,
previous: { vehicleId: '11', driverId: '21', ...originalTransport }, replacement: { vehicleId: '12', driverId: '22' },
capacity: { palletSpaces: 30, weightKg: 18000, volumeCbm: 90 }, cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: pallets.map(row=>row.warehousePalletId), occupied: { palletSpaces: pallets.length, weightKg: pallets.length*100, volumeCbm: pallets.length } } }
if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER'
if (fault === 'preview-identity') preview.replacement.driverId = 'OTHER'
if (fault === 'preview-capacity') preview.capacity.palletSpaces = 29
if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2
return ok(preview)
}
@@ -120,8 +141,10 @@ function fixture(fault = '') {
return ok(handover)
}
assert.equal(String(body.recordVersion), handover.recordVersion)
resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location }
handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); return ok(handover)
resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location,
idempotencyKey: body.idempotencyKey, vehicleStatus: body.vehicleStatus }
handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump();
return ok(fault === 'release-version' && vehicle ? { ...handover, recordVersion: '99' } : handover)
}
if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings })
throw new Error(`Unexpected test route ${method} ${path}`)
@@ -210,7 +233,9 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di
assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING')
})
for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接替司机返回身份和版本', 'driver-identity': '接替司机返回身份和版本', 'upload-identity': '测试证照有正式文件归属',
'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': '旧请求重放不能释放下一计划车辆' })) {
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
@@ -247,8 +272,35 @@ test('explicit closure cleanup cancels only test probes and disables newly creat
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
})
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 () => {
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)
assert.equal(result.preview.cargo.remainingPalletIds.length, 3)
f.proof.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' }
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')
assert.equal((await cleanupFleetHandoverResources(f.context, 'test', f.proof)).status, 'COMPLETED')
})
test('explicit reviewed vehicle reuse skips the original POST and proceeds from driver creation', async () => {
const f = fixture('reviewed-vehicle'), snapshot = copy(f.vehicles.get('12'))
const reviewedVehicle = { snapshot, occupation: { vehicleId: '12', assignmentCount: 0 },
creationRequest: { method: 'POST', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't',
body: { plateNumber: 'HO-24255909-3bc' }, result: snapshot } }
const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails, { reviewedVehicle })
assert.equal(result.status, 'VERIFIED'); assert.equal(result.replacement.vehicleId, '12')
const created = f.calls.filter(row=>row.method==='POST' && row.path?.endsWith('/master-data/vehicles'))
assert.equal(created.length, 1); assert.match(created[0].body.plateNumber, /^HO-PROBE-/)
})
test('internal helper has no environment loader, SQL or standalone execution path', async () => {
const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8')
assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/)
assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails\)/)
assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails, \{ reviewedVehicle \} = \{\}\)/)
})
@@ -80,7 +80,7 @@ function thousandths(value) {
}
/** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails) {
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails, { reviewedVehicle } = {}) {
const run = requireRun(context, tripProof)
if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length)
throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑')
@@ -135,28 +135,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm }
check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value)))
&& Date.parse(detail.trip.expectedDriverEndAt) > Date.now())
const marker = `HO-${randomUUID().slice(0, 12)}`
const marker = reviewedVehicle ? reviewedVehicle.snapshot.plateNumber : `HO-${randomUUID().slice(0, 12).toUpperCase()}`
const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` }
proof.replacement = { marker, vehicleRequest: vehicleBody }
const vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
let vehicle
if (reviewedVehicle) {
check('显式沿用车辆具有已审阅成功请求和无占用证明', reviewedVehicle.creationRequest?.status === 'RESPONDED'
&& reviewedVehicle.creationRequest.http === 200 && text(reviewedVehicle.creationRequest.code) === '200'
&& reviewedVehicle.creationRequest.requestId && reviewedVehicle.creationRequest.traceId
&& text(reviewedVehicle.occupation?.vehicleId) === text(reviewedVehicle.snapshot.id) && reviewedVehicle.occupation.assignmentCount === 0)
vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(reviewedVehicle.snapshot.id)}`, undefined, '再次核对已创建接替车辆完整快照')
check('已创建车辆未发生身份版本容量或状态变更', same(vehicle, reviewedVehicle.snapshot)
&& text(vehicle.recordVersion) === '0' && vehicle.status === 'IN_SERVICE'
&& vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED'
&& Number(vehicle.maxPalletSpaces) === capacity.maxPalletSpaces && Number(vehicle.maxLoadKg) === capacity.maxLoadKg && Number(vehicle.maxVolumeCbm) === capacity.maxVolumeCbm)
proof.replacement.vehicleReuse = clone(reviewedVehicle)
} else vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
const driver = await request('POST', `${FLEET}/drivers`, { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
const driverBody = { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
remark: `${context.marker} TEST credential fixture; not a real driving licence` }, '建立本轮接替司机档案')
check('接替司机返回身份和版本', driver?.id && validVersion(driver.recordVersion))
remark: `${context.marker} TEST credential fixture; not a real driving licence` }
const driver = await request('POST', `${FLEET}/drivers`, driverBody, '建立本轮接替司机档案')
const profileFields = ['warehouseId', 'vehicleModels', 'languagesAndArea', 'medicalExpiresOn', 'trainingExpiresOn', 'insuranceExpiresOn']
check('接替司机返回身份和版本', driver?.id && text(driver.recordVersion) === '0' && driver.active === true
&& Object.entries(driverBody).every(([field, value]) => driver.details?.[field] === value)
&& driver.details.licenseIssuedOn == null && driver.details.profile && !Array.isArray(driver.details.profile)
&& profileFields.every(field => driver.details.profile[field] == null))
proof.replacement.driverId = text(driver.id)
const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate }
proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true }
const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file })
check('测试证照有正式文件归属', uploaded?.licenseFileId && validVersion(uploaded.recordVersion))
check('测试证照有正式文件归属', text(uploaded?.id) === text(driver.id) && uploaded?.licenseFileId
&& text(uploaded.recordVersion) === String(BigInt(driver.recordVersion) + 1n))
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
check('接替司机已正式审核', reviewed?.reviewStatus === 'APPROVED')
check('接替司机已正式审核', text(reviewed?.id) === text(driver.id) && reviewed.reviewStatus === 'APPROVED'
&& text(reviewed.recordVersion) === String(BigInt(uploaded.recordVersion) + 1n) && reviewed.licenseFileId === uploaded.licenseFileId)
proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id,
expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() }
const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格')
check('交接预览绑定原车次版本和确切新旧身份', preview?.tripNo === tripProof.tripNo
&& text(preview.recordVersion) === text(proof.selection.recordVersion)
&& text(preview.previous?.vehicleId) === proof.previous.vehicleId && text(preview.previous?.driverId) === proof.previous.driverId
&& text(preview.replacement?.vehicleId) === text(vehicle.id) && text(preview.replacement?.driverId) === text(driver.id))
check('交接预览容量与已核对车辆一致', Number(preview.capacity?.palletSpaces) === capacity.maxPalletSpaces
&& Number(preview.capacity?.weightKg) === capacity.maxLoadKg && Number(preview.capacity?.volumeCbm) === capacity.maxVolumeCbm)
const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages }))
check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true
@@ -171,7 +196,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
proof.preview = preview
proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() }
const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接')
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && validVersion(handover.recordVersion)
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && text(handover.recordVersion) === '0'
&& text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId
&& text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id)
&& same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null)
@@ -194,11 +219,21 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release`
proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() }
const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务')
check('原车辆已结束而原司机仍在任务中', vehicleReleased.vehicleRelease?.actualEndAt && vehicleReleased.driverRelease == null)
check('原车辆已结束而原司机仍在任务中', vehicleReleased.handoverNo === handover.handoverNo && vehicleReleased.tripNo === tripProof.tripNo
&& text(vehicleReleased.recordVersion) === String(BigInt(proof.vehicleReleaseRequest.recordVersion) + 1n)
&& Date.parse(vehicleReleased.vehicleRelease?.actualEndAt) === Date.parse(proof.vehicleReleaseRequest.actualEndAt)
&& vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey
&& vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE'
&& vehicleReleased.driverRelease == null)
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
check('原车辆司机分别保留结束事实', released.vehicleRelease?.actualEndAt && released.driverRelease?.actualEndAt)
check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo
&& text(released.recordVersion) === String(BigInt(proof.driverReleaseRequest.recordVersion) + 1n)
&& same(released.vehicleRelease, vehicleReleased.vehicleRelease)
&& Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt)
&& released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey
&& released.driverRelease?.location === proof.driverReleaseRequest.location)
const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划')
firstProbe.recordVersion = assigned.trip.recordVersion
check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId)
@@ -208,7 +243,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot))
const secondProbe = await createProbe(2)
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10)}` }
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` }
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })