import { randomUUID } from 'node:crypto' const OPTIONS = { scenario: 'fleet-external-reservation', deploymentBlocker: true } const text = value => value == null ? '' : String(value) const success = response => response?.http === 200 && text(response?.body?.code) === '200' const conflict = response => response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409') const key = () => `fleet-reservation-${randomUUID()}` const stable = value => JSON.stringify(value) const version = value => /^(0|[1-9]\d*)$/.test(text(value)) const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) /** 仅生成明确TEST计划占用;这些预计值不提供真实装载测量证据。 */ export function testPlannedCapacity(cargo, stops) { const pallets = new Map() if (!Array.isArray(cargo) || !cargo.length || !Array.isArray(stops)) throw new Error('计划容量缺少本轮真实货物或路线') for (const row of cargo) { if (!row.warehousePalletId || !row.palletNo || !row.destinationKey) throw new Error('计划货物缺少真实实托和目的地') const matches = stops.map((stop, index) => stop.destinationKey === row.destinationKey ? index : -1).filter(index => index > 0) if (matches.length !== 1) throw new Error('计划货物目的地不在唯一路线卸货站') const palletId = text(row.warehousePalletId), deliveryIndex = matches[0] if (pallets.has(palletId) && pallets.get(palletId).deliveryIndex !== deliveryIndex) throw new Error('同一实托存在不同计划卸货站') pallets.set(palletId, { warehousePalletId: palletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) } return [...pallets.values()] } /** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */ export async function verifyFleetBookingReservation(context, module, tripProof) { if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation) throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据') const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] } const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` async function check(name, passed, response, evidence = '') { context.assert(module, name, response, Boolean(passed), evidence, OPTIONS) if (!passed) throw new Error(`${module}: ${name}`) } async function request(method, suffix, body, label, expectedConflict = false) { const response = await context.request(method, `${base}${suffix}`, body) proof.requests.push({ method, suffix, label, ...trace(response) }) const expectedResult = expectedConflict ? conflict(response) && (!(expectedConflict instanceof RegExp) || expectedConflict.test(text(response?.body?.message))) : success(response) await check(label, expectedResult && Boolean(response?.requestId) && Boolean(response?.traceId), response, JSON.stringify(trace(response))) return response.body?.data } const detail = await request('GET', '', undefined, '读取本轮车次与货物归属') await check('占位验收保留当前车次及分配资源', detail?.trip?.status === 'PLANNED' && text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId) && detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)) const initial = await request('GET', '/capacity-plan', undefined, '读取拣货前真实自有货容量依据') await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason && initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey) && version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || '')) const pallets = testPlannedCapacity(initial.cargo, tripProof.stops) proof.planningBasis = { kind: 'CONTROLLED_TEST_PLAN', actual: false, pallets, remark: '仅预计每实托1托位/100kg/1m³,用于计划余位测试;不得作为装车完成实测回执' } const plan = await request('POST', '/capacity-plan', { recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets, reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量', }, '确认本轮自有货计划容量') await check('保存容量计划不冒充实物装载', plan?.planValid === true && plan.capacityPlan?.manifestHash === initial.manifestHash && plan.availableSegments?.length === tripProof.stops.length - 1) const loading = await request('GET', '/loading', undefined, '回查计划容量没有生成实载确认') await check('实载确认仍待执行', loading?.confirmedLoad == null) proof.initialSegments = plan.availableSegments const destinationKey = initial.cargo[0].destinationKey const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey) const affected = plan.availableSegments.filter(row => row.fromIndex < destinationIndex) await check('本轮测试车次确有可销售余位', destinationIndex > 0 && affected.length === destinationIndex && affected.every(row => Number(row.remaining?.palletSpaces) >= 1 && Number(row.remaining?.weightKg) >= 1 && Number(row.remaining?.volumeCbm) >= 0.01)) let tripVersion = plan.recordVersion const draftBody = { tripRecordVersion: tripVersion, customerId: text(initial.cargo[0].customerId), idempotencyKey: key(), cargo: { destinationKey, cargoName: `${context.marker} 外接占位验收`, packageCount: 1, palletSpaces: 1, weightKg: 1, volumeCbm: 0.01, priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY', remark: '仅验证询价及承运占位;不创建来货、不写库存或财务' } } proof.createRequest = draftBody const created = await request('POST', '/bookings', draftBody, '登记本轮外接询价') await check('外接询价正确归属本车客户及目的地', created?.booking?.status === 'DRAFT' && created.booking.tripNo === tripProof.tripNo && created.booking.customerId === draftBody.customerId && created.booking.cargo?.destinationKey === destinationKey && created.booking.bookingNo) proof.bookings.push(created.booking.bookingNo) const number = encodeURIComponent(created.booking.bookingNo) tripVersion = created.tripRecordVersion const replay = await request('POST', '/bookings', draftBody, '重放相同外接登记请求') await check('登记重放没有新预订或版本推进', replay?.replayed === true && replay.booking?.bookingNo === created.booking.bookingNo && text(replay.tripRecordVersion) === text(tripVersion) && text(replay.booking?.recordVersion) === text(created.booking.recordVersion)) await request('POST', '/bookings', { ...draftBody, cargo: { ...draftBody.cargo, packageCount: 2 } }, '相同请求键改变货量被拒绝', true) const draftCapacity = await request('GET', '/capacity-plan', undefined, '回查询价与错误重试后的余位') await check('询价及错误重试没有占位', stable(draftCapacity.availableSegments) === stable(proof.initialSegments) && text(draftCapacity.recordVersion) === text(tripVersion)) const confirmBody = { tripRecordVersion: tripVersion, recordVersion: created.booking.recordVersion, idempotencyKey: key() } proof.confirmRequest = confirmBody const confirmed = await request('POST', `/bookings/${number}/confirm`, confirmBody, '确认本轮外接承运') await check('承运确认保持约定报价且不假收货', confirmed?.booking?.status === 'CONFIRMED' && Number(confirmed.booking.agreedAmount) === 25 && !confirmed.booking.incomingNo && !confirmed.booking.dispatchNo) tripVersion = confirmed.tripRecordVersion const reserved = await request('GET', '/capacity-plan', undefined, '读取确认承运后的分段余位') const expected = proof.initialSegments.map(segment => { if (segment.fromIndex >= destinationIndex) return segment const sum = (amount, delta) => Math.round((Number(amount) + delta) * 1000) / 1000 return { ...segment, occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1, weightKg: sum(segment.occupied.weightKg, 1), volumeCbm: sum(segment.occupied.volumeCbm, 0.01) }, remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1, weightKg: sum(segment.remaining.weightKg, -1), volumeCbm: sum(segment.remaining.volumeCbm, -0.01) } } }) await check('只在起运至约定卸货站逐段扣减一次', stable(reserved.availableSegments) === stable(expected) && reserved.planValid === true && text(reserved.recordVersion) === text(tripVersion)) proof.reservedSegments = reserved.availableSegments const confirmReplay = await request('POST', `/bookings/${number}/confirm`, confirmBody, '重放承运确认') const afterReplay = await request('GET', '/capacity-plan', undefined, '核对承运重放后的容量') await check('重复承运确认不二次占位', confirmReplay?.replayed === true && text(confirmReplay.tripRecordVersion) === text(tripVersion) && text(afterReplay.recordVersion) === text(tripVersion) && stable(afterReplay.availableSegments) === stable(expected)) const availableSpaces = Math.min(...reserved.availableSegments.filter(row => row.fromIndex < destinationIndex).map(row => Number(row.remaining.palletSpaces))) const excess = await request('POST', '/bookings', { ...draftBody, tripRecordVersion: tripVersion, idempotencyKey: key(), cargo: { ...draftBody.cargo, palletSpaces: availableSpaces + 1 } }, '登记超出余位但未确认的第二条询价') await check('第二条仍为询价', excess?.booking?.status === 'DRAFT' && excess.booking.bookingNo !== created.booking.bookingNo) proof.bookings.push(excess.booking.bookingNo) tripVersion = excess.tripRecordVersion await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/confirm`, { tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(), }, '超卖确认被拒绝', /超过车辆托位、重量或容积限制/) const rejected = await request('GET', '/bookings', undefined, '回查超卖拒绝后的预订状态') const rejectedCapacity = await request('GET', '/capacity-plan', undefined, '回查超卖拒绝后的分段余位') await check('超卖失败保持询价和已承诺余位', rejected.records?.find(row => row.bookingNo === excess.booking.bookingNo)?.status === 'DRAFT' && text(rejected.tripRecordVersion) === text(tripVersion) && text(rejectedCapacity.recordVersion) === text(tripVersion) && stable(rejectedCapacity.availableSegments) === stable(expected)) const excessCancelled = await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/cancel`, { tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(), reason: '结束未承运的超容量测试询价', }, '正式取消未承运测试询价') tripVersion = excessCancelled.tripRecordVersion await check('未承运询价取消成功', excessCancelled.booking?.status === 'CANCELLED') const cancelBody = { tripRecordVersion: tripVersion, recordVersion: confirmed.booking.recordVersion, idempotencyKey: key(), reason: '结束本轮未执行外接预订并验证余位释放' } proof.cancelRequest = cancelBody const cancelled = await request('POST', `/bookings/${number}/cancel`, cancelBody, '正式取消已承运但未执行预订') tripVersion = cancelled.tripRecordVersion await check('已承运预订正式取消', cancelled.booking?.status === 'CANCELLED') const cancelReplay = await request('POST', `/bookings/${number}/cancel`, cancelBody, '重放取消承运请求') const released = await request('GET', '/capacity-plan', undefined, '回查取消与重放后的全部余位') await check('取消仅释放一次并恢复原自有货余位', cancelReplay?.replayed === true && text(cancelReplay.tripRecordVersion) === text(tripVersion) && text(released.recordVersion) === text(tripVersion) && stable(released.availableSegments) === stable(proof.initialSegments) && released.planValid === true) proof.finalSegments = released.availableSegments proof.finalTripVersion = tripVersion proof.status = 'VERIFIED' return proof }