297 lines
23 KiB
JavaScript
297 lines
23 KiB
JavaScript
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
|
||
|
|
}
|