import { createHash, randomUUID } from 'node:crypto' const FLEET = '/api/platform/v1/last-leg/fleet' const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles' const DISPATCHES = '/api/platform/v1/last-leg/dispatches' const OPTIONS = { scenario: 'fleet-in-transit-handover', deploymentBlocker: true } const text = value => value == null ? '' : String(value) const key = () => `handover-${randomUUID()}` const clone = value => value == null ? value : structuredClone(value) const stable = value => JSON.stringify(canonical(value)) function canonical(value) { if (Array.isArray(value)) return value.map(canonical) if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(name => [name, canonical(value[name])])) return value } const same = (left, right) => stable(left) === stable(right) const validVersion = value => (typeof value !== 'number' || Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(text(value)) const transport = value => ({ vehiclePlate: value?.vehiclePlate ?? null, driverName: value?.driverName ?? null, driverPhone: value?.driverPhone ?? null }) const count = value => Number.isSafeInteger(Number(value)) && Number(value) > 0 const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) const pathFor = number => `${FLEET}/trips/${encodeURIComponent(number)}` const certificate = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') function requireRun(context, tripProof) { const run = context.createdResources?.fleetAcceptance if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt) throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本') return run } function io(context, module, proof) { const checkpoint = context.context?.checkpointFleetHandover if (typeof checkpoint !== 'function') throw new Error('交接验收必须注入发送前持久化检查点') const check = (label, passed, response = null, evidence = '') => { context.assert(module, label, response, Boolean(passed), evidence, OPTIONS) if (!passed) throw new Error(`${module}: ${label}`) } async function request(method, path, body, label, { token, conflict, file } = {}) { const entry = { method, path, label, actor: token ? 'PDA' : 'PLATFORM', body: clone(body), status: 'SENDING' } if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), sizeBytes: file.content.length } proof.requests.push(entry) // A failed write must stop before HTTP. Keep the durable SENDING record if the process dies in flight. await checkpoint(proof) try { const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token) Object.assign(entry, trace(response), { status: 'RESPONDED', result: clone(response?.body?.data) }) await checkpoint(proof) const accepted = conflict ? (response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409')) && conflict.test(text(response?.body?.message)) : response?.http === 200 && text(response?.body?.code) === '200' check(label, accepted && response?.requestId && response?.traceId, response, stable(trace(response))) return response.body?.data } catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT' await checkpoint(proof) } throw error } } return { check, request } } function dispatchFacts(row) { const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) } function feeFacts(rows) { if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表') return rows.map(row => Object.fromEntries(['sourceFeeId', 'sourceVersion', 'sourceStatus', 'feeItemCode', 'direction', 'originalAmount', 'originalCurrency'] .map(name => [name, row[name]]))).sort((a, b) => text(a.sourceFeeId).localeCompare(text(b.sourceFeeId))) } function inspectionFacts(value) { return { ...value, remainingPalletIds: [...(value?.remainingPalletIds || [])].sort() } } function thousandths(value) { const match = /^(\d+)(?:\.(\d{1,3}))?$/.exec(text(value)) if (!match) throw new Error('实测载荷不是有效的三位小数') return BigInt(match[1]) * 1000n + BigInt((match[2] || '').padEnd(3, '0')) } /** 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, { reviewedVehicle } = {}) { const run = requireRun(context, tripProof) if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length) throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑') const numbers = dispatchDetails.map(row => row.dispatchNo) if (new Set(numbers).size !== numbers.length || numbers.some(number => !number) || !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort()) || !dispatchDetails.every(row => count(row.packageCount)) || Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0)) throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车') const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [], dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' } const { check, request } = io(context, module, proof), base = pathFor(tripProof.tripNo) try { const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源') check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT' && detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId) && validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort())) const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据') check('原始发车历史唯一且没有此前交接', Array.isArray(initialHistory?.records) && Number(initialHistory.total) === initialHistory.records.length && initialHistory.records.filter(row => row.action === 'DEPART').length === 1 && !initialHistory.records.some(row => row.action === 'HANDOVER')) proof.originalDeparture = clone(initialHistory.records.find(row => row.action === 'DEPART')) const original = proof.originalDeparture.snapshot check('原DEPART装载确属本车及当前分配', original?.trip?.tripNo === tripProof.tripNo && original.trip.departedAt === detail.trip.departedAt && text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId) && original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0) proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) } proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = [] for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId), packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId)) check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) && binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId)) && same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) proof.dispatchBefore.push(dispatchFacts(row)) const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo && pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0 && pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip))) && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount)) proof.pdaBefore.push(pda) for (const delivery of pda.deliveries) { const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) }) } } 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 = 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 } 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 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` } 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('测试证照有正式文件归属', 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('接替司机已正式审核', 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 && same(preview.cargo?.remainingCargo, expectedCargo) && preview.cargo.completedDeliveries?.length === 0 && same([...preview.cargo.remainingPalletIds].sort(), [...new Set(expectedCargo.map(row => row.palletId))].sort())) const measured = original.confirmedLoad.pallets check('剩余载荷沿用原DEPART实测且共享实托只计一次', new Set(measured.map(row => row.warehousePalletId)).size === measured.length && same(measured.map(row => row.warehousePalletId).sort(), [...preview.cargo.remainingPalletIds].sort()) && Number(preview.cargo.occupied?.palletSpaces) === measured.reduce((sum, row) => sum + Number(row.palletSpaces), 0) && thousandths(preview.cargo.occupied?.weightKg) === measured.reduce((sum, row) => sum + thousandths(row.weightKg), 0n) && thousandths(preview.cargo.occupied?.volumeCbm) === measured.reduce((sum, row) => sum + thousandths(row.volumeCbm), 0n)) 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 && 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) proof.handoverNo = handover.handoverNo; proof.confirmed = handover const persisted = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化交接事实') check('只生成一份持久化交接记录', Number(persisted?.total) === 1 && persisted.records?.[0]?.handoverNo === handover.handoverNo) proof.persisted = persisted.records[0] const replay = await request('POST', `${base}/handovers`, proof.confirmRequest, '原交接请求重放') check('重放返回同一持久化记录而不重复交接', same(replay, proof.persisted)) const after = await request('GET', base, undefined, '交接后读取车次状态和当前资源') check('交接保持在途且版本只增加一次', after.trip?.status === 'IN_TRANSIT' && after.trip.departedAt === detail.trip.departedAt && BigInt(text(after.trip.recordVersion)) === BigInt(text(detail.trip.recordVersion)) + 1n && text(after.trip.vehicleId) === text(vehicle.id) && text(after.trip.driverId) === text(driver.id)) proof.staleRequest = { ...clone(proof.confirmRequest), idempotencyKey: key() } await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ }) check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after)) const firstProbe = await createProbe(1) const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' } await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) 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.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.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) const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录') const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo) check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot)) 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).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: /司机.*占用/ }) for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected)) proof.pdaAfter = [] for (const previous of proof.pdaBefore) { const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken }) check('PDA保持原发车资源并展示每票接替资源', current.dispatchNo === previous.dispatchNo && current.tripNo === tripProof.tripNo && same(current.originalDeparture, previous.originalDeparture) && current.latestHandover?.handoverNo === proof.handoverNo && same(current.deliveries.map(({ transport: ignored, ...rest }) => rest), previous.deliveries.map(({ transport: ignored, ...rest }) => rest)) && current.deliveries.every(row => same(transport(row.transport), transport(handover.snapshot.replacement)))) proof.pdaAfter.push(current) } for (const fees of proof.feesBefore) check('交接不新增或改写派送费用', same(feeFacts(await request('GET', fees.path, undefined, '交接后核对原费用来源')), fees.facts)) const historyAfter = await request('GET', `${base}/history?page=1&size=200`, undefined, '交接完成后核对原DEPART不变') check('只新增一次交接且原DEPART内容保持不变', historyAfter.records?.filter(row => row.action === 'HANDOVER').length === 1 && same(historyAfter.records.filter(row => row.action === 'DEPART'), [proof.originalDeparture])) const final = await request('GET', base, undefined, '保存当前接替资源供后续真实签收结案') proof.finalTrip = final.trip; proof.requestCount = proof.requests.length proof.pdaReadCount = proof.requests.filter(row => row.actor === 'PDA').length proof.browser = { tripNo: tripProof.tripNo, dispatchNos: numbers, handoverNo: proof.handoverNo, originalVehiclePlate: proof.previous.vehiclePlate, replacementVehiclePlate: handover.snapshot.replacement.vehiclePlate, certificateFileId: proof.replacement.licenseFileId, expectedPdaReadsPerOpenedDispatch: 1 } proof.status = 'VERIFIED' // Caller finishFleetTrip must finish the currently assigned resources, not the original pair. tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId return proof } catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error } async function createProbe(index) { const saved = { marker: context.marker, index }; proof.probes.push(saved) const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(), plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划') check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion)) saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved } } /** Optional explicit cleanup, only after the caller signed every shipment and released the current trip's resources. */ export async function cleanupFleetHandoverResources(context, module, tripProof) { requireRun(context, tripProof) const proof = tripProof.handover if (proof?.status !== 'VERIFIED' || proof.marker !== context.marker || proof.cleanup) throw new Error('仅允许已验证本轮交接的一次性结案后清理;失败不自动重试') const cleanup = proof.cleanup = { status: 'CHECKING', requests: [] }, { check, request } = io(context, module, cleanup) try { const current = await request('GET', pathFor(tripProof.tripNo), undefined, '清理前核对整车货物结案及当前资源释放') check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease && text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId) for (const probe of [...proof.probes].reverse()) { const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划') check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0) const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划') check('无货计划已取消', cancelled.trip?.status === 'CANCELLED') } for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) { const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本') check('停用目标确属本轮新建车辆', vehicle.remark?.includes(context.marker) && text(vehicle.id) === vehicleId) const stopped = await request('PATCH', `${VEHICLES}/${encodeURIComponent(vehicleId)}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮新增车辆并保留记录') check('本轮车辆已停用', stopped.status === 'INACTIVE') } const driverPath = `${FLEET}/drivers/${encodeURIComponent(proof.replacement.driverId)}` const driver = await request('GET', driverPath, undefined, '读取本轮接替司机版本') check('停用司机确属本轮受控档案', driver.details?.remark?.includes(context.marker) && text(driver.id) === proof.replacement.driverId) const stopped = await request('PATCH', `${driverPath}/status`, { recordVersion: driver.recordVersion, active: false, reason: '本轮完整验收结束,保留测试证照与审计记录' }, '停用本轮接替司机') check('本轮接替司机已停用', stopped.active === false) cleanup.status = 'COMPLETED'; return cleanup } catch (error) { cleanup.status = 'FAILED'; cleanup.failure = error.message; throw error } }