152 lines
12 KiB
JavaScript
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
|
|
}
|