Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs
T

297 lines
23 KiB
JavaScript
Raw Normal View History

import { createHash, randomUUID } from 'node:crypto'
const BASE = '/api/platform/v1/last-leg/fleet'
const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const version = value => {
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value)))
throw new Error('服务端车次版本无效')
return BigInt(String(value))
}
async function command(context, module, method, path, body, label) {
const response = await context.request(method, path, body)
const data = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`)
return data
}
/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
const marker = `FT-${randomUUID().slice(0, 12)}`
const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } }
context.createdResources.fleetTrips ??= []
context.createdResources.fleetTrips.push(proof)
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
remark: `${context.marker} controlled TEST vehicle`,
}, '建立本轮真实车辆档案及容量')
proof.vehicleId = id(vehicle.id)
await prepareQualifiedDriver(context, module, marker, proof)
const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString()
const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString()
const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex')
let detail = await command(context, module, 'POST', `${BASE}/trips`, {
warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey,
stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker,
}, '创建本轮共享运输车次')
proof.tripNo = detail.trip.tripNo
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
detail = await command(context, module, 'POST', `${path}/assignment`, {
recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车',
}, '正式分配车辆司机并核验证照')
detail = await command(context, module, 'POST', `${path}/dispatches/bind`, {
recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据',
}, '将客户排车单关联至车次')
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键')
const destinations = new Map()
for (const cargo of loading.cargo || []) {
if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点')
destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey })
}
if (!destinations.size) throw new Error('实际排车没有货物')
detail = await command(context, module, 'PUT', `${path}/plan`, {
recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt,
stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线',
}, '在拣货前匹配真实卸货站')
proof.stops = detail.trip.stops
detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, {
recordVersion: detail.trip.recordVersion, decision: 'APPROVED',
}, '拣货前通过本轮客户排车调度审核')
proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review
if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备')
return proof
}
async function prepareQualifiedDriver(context, module, marker, proof) {
let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC',
licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
remark: `${context.marker} TEST credential fixture; not a real driving licence`,
}, '建立本轮司机证照档案')
proof.driverId = id(driver.id)
const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, {
name: `${marker}-TEST-LICENSE.png`, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'),
})
driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS)
if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格')
proof.licenseFileId = id(driver.licenseFileId)
const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, {
recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机',
}, '通过正式命令审核测试司机')
if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证')
}
export function controlledPalletMeasurements(cargo, stops) {
const pallets = new Map()
for (const item of cargo) {
if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地')
const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey)
if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线')
const key = id(item.warehousePalletId)
if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托')
// Controlled test measurement, deliberately separate from source billing/allocated mass.
pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex })
}
if (!pallets.size) throw new Error('没有实际装载托盘')
return [...pallets.values()]
}
export async function confirmFleetLoading(context, module, proof) {
const state = proof.reassignment
if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入')
if (state) state.status = 'CHECKING'
const confirmed = await confirmActualLoad(context, module, proof)
if (!state) return approveLoading(context, module, proof, confirmed.recordVersion)
state.originalConfirmation = confirmed
await verifyPreDepartureReassignment(context, module, proof, confirmed)
const reconfirmed = await confirmActualLoad(context, module, proof)
check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash
&& JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo)
&& version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion))
state.finalConfirmation = reconfirmed
const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion)
state.status = 'VERIFIED'
return reviewedVersion
}
async function approveLoading(context, module, proof, recordVersion) {
const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, {
recordVersion, decision: 'APPROVED',
}, '实际装载确认后通过整车理货审核')
proof.loadingReview = detail.loadingReview
if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车')
proof.recordVersion = detail.trip.recordVersion
return proof.recordVersion
}
async function confirmActualLoad(context, module, proof) {
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading`
const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据')
if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`)
const pallets = controlledPalletMeasurements(loading.cargo, proof.stops)
const confirmed = await command(context, module, 'POST', path, {
recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets,
reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量',
}, '通过正式命令确认受控测试装载')
proof.confirmedLoad = confirmed.confirmedLoad
proof.recordVersion = confirmed.recordVersion
check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash
&& version(confirmed.recordVersion) > version(loading.recordVersion)
&& JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId))))
return confirmed
}
function dispatchWorkSnapshot(row) {
return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId,
status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount,
shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount,
estimatedArrivalAt: row.estimatedArrivalAt, items: row.items })
}
async function verifyPreDepartureReassignment(context, module, proof, confirmed) {
const state = proof.reassignment
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`
const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划')
const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细')
check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED'
&& version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART'
&& before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0
&& before.pickedPackageCount === before.packageCount && before.items?.length > 0
&& id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId)
state.originalTrip = original.trip; state.dispatchBefore = before
const marker = `SWAP-${randomUUID().slice(0, 12)}`
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces,
maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`,
}, '创建本轮等容量替换车辆')
state.replacement = { vehicleId: id(vehicle.id) }
await prepareQualifiedDriver(context, module, marker, state.replacement)
state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId,
driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' }
const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机')
state.assignedTrip = assigned.trip
check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId
&& id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED'
&& version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops)
&& JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row))
&& assigned.dispatches.every(row => row.review?.status === 'PENDING'))
const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细')
state.dispatchAfter = after
check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before)
&& after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone)
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效')
check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null
&& version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash
&& JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo))
state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash,
pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' }
const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest)
const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code)))
state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message }
check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || '')))
const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入')
check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion)
&& unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash)
let reviewed = assigned
for (const binding of assigned.dispatches) {
reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, {
recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED',
}, '换车换司机后重新通过调度审核')
if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过')
}
state.reviewedDispatches = reviewed.dispatches
proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId
proof.licenseFileId = state.replacement.licenseFileId
}
function check(context, module, label, condition, evidence = '') {
context.assert(module, label, null, condition, evidence, OPTIONS)
if (!condition) throw new Error(`${module}: ${label}失败`)
}
async function expectOccupied(context, module, path, body, resource, label) {
const response = await context.request('POST', `${path}/assignment`, body)
const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code)))
const message = String(response?.body?.message || '')
const valid = conflict && message.includes(resource) && message.includes('占用')
context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS)
if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`)
}
/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */
export async function finishFleetTrip(context, module, proof) {
if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明')
if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求')
const state = proof.closure = { status: 'CHECKING', probes: [] }
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件')
check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '')
state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' }
const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物')
check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED'
&& Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null)
const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案')
check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt)
async function probe(index) {
const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`,
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' }
const evidence = { createRequest: request }; state.probes.push(evidence)
const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`)
evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion
return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` }
}
const first = await probe(1)
const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' }
await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用')
const endLocation = proof.stops?.at(-1)?.name
check(context, module, '测试任务具有明确结束地点', Boolean(endLocation))
// These controlled fixtures end at the signed final stop and contain no return-driving task.
state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE',
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE',
idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' }
const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务')
check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null)
await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用')
state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER',
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null,
idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' }
const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务')
check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease))
const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机')
first.evidence.recordVersion = assigned.trip.recordVersion
check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId))
const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求')
const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求')
check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion)
const second = await probe(2)
await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用')
// A different free fixture vehicle isolates the driver claim from the vehicle claim.
const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`,
}, '建立司机占用隔离用测试车辆')
state.spareVehicleId = id(spare.id)
await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用')
for (const item of [second, first]) {
const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划')
check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED')
item.evidence.finalStatus = cancelled.trip.status
}
const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录')
check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease))
state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
state.finalTrip = final.trip
return state
}