Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs
2026-10-20 10:35:52 +08:00

152 lines
12 KiB
JavaScript

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
}