feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution

This commit is contained in:
2026-10-19 23:40:42 +08:00
parent e0677e7ae7
commit 9dd34791f7
346 changed files with 29245 additions and 12602 deletions
+76 -49
View File
@@ -5,8 +5,9 @@ import { createHash, randomUUID } from 'node:crypto'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs'
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs'
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam'
@@ -20,7 +21,6 @@ export function poolLocalDateTime(date) {
requireThat(date instanceof Date && Number.isFinite(date.getTime()), 'VALID_LOCAL_DATE_REQUIRED')
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
}
const localTime = ms => poolLocalDateTime(new Date(Date.now() + ms))
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
export const POOL_COVERAGE = Object.freeze([
@@ -271,9 +271,11 @@ async function verifyPauseAndCutoff({ offerNo, bookingNo, salesToken, dispatcher
async function poolScenario(context, fixture, { execute = false, readInventory, credentials }) {
if (!execute) return poolPlan({ execute: false, runId: 'plan' })
requireThat(fixture?.marker === context.marker && fixture.members?.length === 2 && fixture.pdaToken
&& fixture.dispatchNo && fixture.tripProof?.dispatchNo === fixture.dispatchNo
&& fixture.members.every(member => member.dispatchNo === fixture.dispatchNo)
&& typeof readInventory === 'function', 'CURRENT_PHYSICAL_FIXTURE_REQUIRED')
const proof = context.createdResources.fleetCapacityPool = { status: 'CHECKING', marker: context.marker,
tripNo: fixture.tripNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} }
tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} }
const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo)
const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`
const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex')
@@ -315,7 +317,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
for (let n = 0; n < 2; n++) dispatchers.push(await createActor('dispatch', n, [PREFIX + 'manage']))
const fulfillmentToken = await createActor('fulfillment', 0, [PREFIX + 'manage',
'platform:fleet:trip:booking-edit', 'platform:fleet:trip:booking-confirm', 'platform:fleet:trip:edit',
'platform:overseas-warehouse:inbound-order:smart-dispatch'])
'platform:overseas-warehouse:dispatch:create'])
for (const member of fixture.members) {
const credential = credentials?.get(member.customerId)
check('Original fixture customer credentials available only in memory', Boolean(credential))
@@ -324,15 +326,23 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
await reject('Merchant token cannot read internal capacity pool', 'GET', `${POOL}?page=1&size=20`, undefined, login.accessToken)
}
proof.covered.push('merchant-cannot-read-pool')
const dispatchPath = `${API}/dispatches/${encode(fixture.dispatchNo)}`
let main = await request('GET', dispatchPath)
check('One native main retains both original unpicked customers', main.status === 'ACTIVE' && main.stage === 'LOADING'
&& Number(main.counts?.pickedPackageCount) === 0 && Number(main.counts?.shippedPackageCount) === 0)
await request('POST', `${dispatchPath}/return-to-review`, { recordVersion: main.recordVersion, idempotencyKey: key('RETURN-BEFORE-POOL'), reason: 'TEST零扫码显式退回以追加本轮外接实收' })
main = await request('GET', dispatchPath)
check('Explicit return preserves main identity and all sources before pool changes', main.dispatchNo === fixture.dispatchNo && main.stage === 'BEFORE_LOADING' && main.reviewState === 'DRAFT')
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
check('One actual thirty-space trip and two original real pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
const planned = controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 }))
const planned = testPlannedCapacity(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 }))
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: planned, reason: 'TEST容量计划18托位;实际装载另行实测确认' })
detail = await request('GET', tripPath)
const offerDraft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, dispatchers[0])
let offer = (await request('POST', `${POOL}/${offerDraft.offer.offerNo}/publish`, { tripRecordVersion: offerDraft.offer.tripRecordVersion,
offerRecordVersion: offerDraft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, dispatchers[0])).offer
proof.planEvidence = { actual: false, reason: 'TEST销售占位计划18/30,不能作为实载回执', pallets: planned }
proof.offerNo = offer.offerNo
const destinationKey = plan.cargo[0].destinationKey
check('Owned physical sources share exact destination', plan.cargo.every(row => row.destinationKey === destinationKey))
@@ -458,33 +468,49 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo)
&& history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId))
proof.covered.push('close-and-successful-confirmation-replay')
detail = await request('GET', tripPath)
for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' })
main = await request('GET', dispatchPath)
check('All appended physical pallets remain on the original one native main before review', main.dispatchNo === fixture.dispatchNo
&& main.stage === 'BEFORE_LOADING' && Number(main.counts?.packageCount) === 4 && Number(main.counts?.pickedPackageCount) === 0)
let reviewed = main
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) reviewed = await request('POST', `${dispatchPath}/${action}`,
{ recordVersion: reviewed.recordVersion, idempotencyKey: key(`POOL-${action}`),
...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) })
const members = [...fixture.members, ...poolMembers]
check('Original and pool receipts all belong to the same trip main', members.every(member => member.dispatchNo === fixture.dispatchNo))
proof.pickScopes = []
const namespace = await pickPoolDispatch(context, fixture.pdaToken, fixture.members[0])
proof.pickScopes.push({ dispatchNo: fixture.dispatchNo, customerIds: [...new Set(members.map(row => row.customerId))], namespace })
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken)
check('All four owned and external pallets were really picked within one native main', task.status === 'ACTIVE' && task.stage === 'LOADING'
&& task.pickingReconciled === true && Number(task.counts?.packageCount) === 4 && Number(task.counts?.pickedPackageCount) === 4
&& task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId
&& row.palletNo === member.palletNo && Number(row.pickedPackages) === 1)))
await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements)
const arranged = await arrangeDispatchDelivery(context, fixture.dispatchNo)
check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2
&& arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4)
for (const member of members) {
const namespace = await pickPoolDispatch(context, fixture.pdaToken, member)
proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace })
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken)
check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages)))
const matches = arranged.deliveries.filter(row => id(row.customerId) === member.customerId)
check('Every source maps to its exact customer delivery without invented child dispatches', matches.length === 1)
Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo })
}
const loading = await request('GET', `${tripPath}/loading`)
check('All four original physical pallets present for actual loading', loading.cargo.length === 4 && !loading.blockReason)
const loaded = await request('POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash,
pallets: controlledPalletMeasurements(loading.cargo, detail.trip.stops), reason: 'TEST受控实测每实托1托位;不同于之前18托位接单计划' })
detail = await request('POST', `${tripPath}/loading-review`, { recordVersion: loaded.recordVersion, decision: 'APPROVED' })
const departBody = { recordVersion: detail.trip.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker }
const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.members[0].dispatchNo)}/depart`
const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken)
check('Arrangement leaves native main ready in AFTER_LOADING', readyTask.status === 'ACTIVE' && readyTask.stage === 'AFTER_LOADING' && readyTask.actions?.DEPART?.allowed === true)
const departBody = { recordVersion: readyTask.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker }
const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}/depart`
const departed = await request('POST', departPath, departBody, fixture.pdaToken)
check('One whole-trip actual departure includes exactly four dispatches', departed.status === 'IN_TRANSIT' && Number(departed.shippedPackages) === 4
&& departed.dispatches.length === 4 && members.every(member => departed.dispatches.some(row => row.dispatchNo === member.dispatchNo)))
check('Departure original-key replay is explicit', (await request('POST', departPath, departBody, fixture.pdaToken)).replayed === true)
for (const [index, member] of members.entries()) {
const path = `${API}/delivery-orders/${encode(member.deliveryNo)}?customerId=${encode(member.customerId)}`
const currentDelivery = await request('GET', path)
await request('PUT', path.replace('?', '/status?'), { status: 'DELIVERING', expectedVersion: currentDelivery.order.version, remark: context.marker })
const signed = await saveReceiptAndComplete(context, { deliveryNo: member.deliveryNo }, member.customerId, index)
check('One native actual departure covers four sources and two customer deliveries', departed.dispatchNo === fixture.dispatchNo
&& departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 4
&& departed.result.deliveries.length === 2)
const departReplay = await request('POST', departPath, departBody, fixture.pdaToken)
check('Departure original-key replay preserves exact version and documents', departReplay.replayed === true
&& id(departReplay.recordVersion) === id(departed.recordVersion) && JSON.stringify(departReplay.result) === JSON.stringify(departed.result))
for (const [index, delivery] of arranged.deliveries.entries()) {
const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`)
check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING')
const signed = await saveReceiptAndComplete(context, delivery, delivery.customerId, index)
check('Receipt actually completes this delivery', signed?.order?.status === 'DELIVERED')
check('Signing cannot create or duplicate agreed fees', JSON.stringify(actual.fees) === JSON.stringify(signed.fees))
}
proof.covered.push('shared-real-departure-and-signature')
for (const [index, order] of proof.orders.entries()) {
@@ -494,7 +520,14 @@ async function poolScenario(context, fixture, { execute = false, readInventory,
&& commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1)
const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`)
const fees = actual.fees.filter(row => row.feeType === 'DELIVERY_FEE' && row.feeDirection === 'RECEIVABLE')
check('One agreed receivable remains, no order/receipt duplicate fee', fees.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY')
const sameCustomerOrders = poolMembers.filter(row => row.customerId === order.customerId)
const externalFees = fees.filter(row => row.currency === 'CNY' && Number(row.amount) === 30)
check('Exactly one agreed external receivable per original source plus the separately uncovered owned service',
externalFees.length === sameCustomerOrders.length && fees.length === sameCustomerOrders.length + 1
&& fees.filter(row => row.currency === 'USD' && Number(row.amount) === 10).length === 1
&& new Set(fees.map(row => id(row.id))).size === fees.length
&& sameCustomerOrders.every(source => actual.operations.filter(op => op.operationNode === 'CONFIRM_EXTERNAL_FREIGHT'
&& id(op.result).includes(`外接预订 ${source.bookingNo};`)).length === 1))
const merchant = merchantSessions.find(session => session.customerId === order.customerId)
const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}`
const own = await request('GET', merchantPath, undefined, merchant.accessToken)
@@ -571,9 +604,10 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request
const inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId })
check('Immutable actual incoming STOCK_IN is unique', inventory.scope?.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE'
&& inventory.ledger.filter(row => row.businessOrderNo === incomingNo && row.businessType === 'STOCK_IN' && Number(row.deltaPackages) === 1).length === 1)
const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: incomingNo })}`)
check('Received candidate exactly belongs to this commercial incoming', candidates.length === 1 && candidates[0].inboundCode === incomingNo
&& id(candidates[0].customerId) === order.customerId && id(candidates[0].warehousePalletId) === id(pallet.warehousePalletId))
const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: incomingNo })}`)
check('Received native candidate exactly belongs to this commercial incoming', candidates.complete === true && candidates.candidates.length === 1
&& candidates.candidates[0].inboundCode === incomingNo && id(candidates.candidates[0].customerId) === order.customerId
&& candidates.candidates[0].palletNo === pallet.palletNo && candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN')
bookingList = await request('GET', `${tripPath}/bookings`); booking = bookingList.records.find(row => row.bookingNo === order.bookingNo)
const arrangeBody = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion,
idempotencyKey: key(`ARRANGE-DISPATCH-${index}`) }
@@ -581,13 +615,13 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request
const arranged = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken)
const arrangeReplay = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken)
check('One command generates and binds the actual receipt without a manually supplied number', arranged.booking.status === 'BOUND'
&& arranged.booking.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId)
&& arranged.booking.dispatchNo === fixture.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId)
check('Arrange replay returns the original dispatch and version', arrangeReplay.replayed === true
&& arrangeReplay.booking.dispatchNo === arranged.booking.dispatchNo && arrangeReplay.tripRecordVersion === arranged.tripRecordVersion)
const dispatch = { dispatchNo: arranged.booking.dispatchNo }
if (index === 0) proof.covered.push('pool-fulfillment-explicit-write-permissions', 'pool-arrange-dispatch-without-manual-number')
let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath)
const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops)
const plannedMeasurements = testPlannedCapacity(plan.cargo, detail.trip.stops)
const restored = await verifyPoolBindingRevalidation({ offerDetail, check,
blockedConfirm: async label => {
const before = await latest(proof.revalidationDraftNo)
@@ -597,7 +631,7 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request
check('Revalidation refusal keeps competing draft unchanged', inquiryFact(before) === inquiryFact(await latest(before.bookingNo)))
},
replan: () => request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets: actualMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }),
pallets: plannedMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }),
resume: async offer => (await request('POST', `${POOL}/${proof.offerNo}/resume`, { tripRecordVersion: offer.tripRecordVersion,
offerRecordVersion: offer.recordVersion, idempotencyKey: key(`RESUME-AFTER-BIND-${index}`), reason: 'TEST实际绑定核量后显式恢复接单' }, dispatcherToken)).offer })
const bound = await latest(order.bookingNo)
@@ -605,34 +639,27 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request
const allBookings = await request('GET', `${tripPath}/bookings`)
const load = rows => rows.reduce((total, row) => { for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) total[dimension] += Number(row[dimension]); return total },
{ palletSpaces: 0, weightKg: 0, volumeCbm: 0 })
const plannedLoad = load(actualMeasurements)
const plannedLoad = load(plannedMeasurements)
const reservedLoad = load(allBookings.records.filter(row => ['CONFIRMED', 'RECEIVED'].includes(row.status)).map(row => row.cargo))
const capacity = { palletSpaces: detail.trip.maxPalletSpaces, weightKg: detail.trip.maxLoadKg, volumeCbm: detail.trip.maxVolumeCbm }
assertPoolBoundCapacity(restored, destinationKey, plannedLoad, reservedLoad, capacity)
check('BOUND reservation is removed exactly once from all capacity dimensions', Number(plannedLoad.palletSpaces) === 3 + index
&& Number(reservedLoad.palletSpaces) === (index === 0 ? 4 : 0))
;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, planned: plannedLoad,
;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, actual: false, planned: plannedLoad,
reserved: reservedLoad, remaining: restored.destinations.find(row => row.destinationKey === destinationKey).remaining })
const path = `${dispatchBase}/${encode(dispatch.dispatchNo)}`, freight = await request('GET', `${path}/external-freight`)
check('The one pool commercial source owns the one agreed freight', freight.requiresDeliveryFee === false && freight.quotes.length === 1
&& freight.quotes[0].bookingNo === order.bookingNo && Number(freight.quotes[0].amount) === 30)
detail = await request('GET', tripPath)
const transfer = { idempotencyKey: key(`TRANSFER-${index}`), plannedDeliveryAt: localTime(21600000), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP',
loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', vehiclePlate: detail.trip.vehiclePlate, driverName: detail.trip.driverName,
driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', externalFreightHash: freight.snapshotHash, fees: [], remark: context.marker }
const converted = await request('POST', `${path}/transfer-delivery`, transfer)
check('Pool source produces exactly one outbound and delivery', converted.outboundNo && converted.deliveries.length === 1)
const replay = await request('POST', `${path}/transfer-delivery`, transfer)
check('Transfer replay reuses outbound and delivery', replay.replayed === true && replay.outboundNo === converted.outboundNo && replay.deliveries[0].deliveryNo === converted.deliveries[0].deliveryNo)
const nativeItems = await request('GET', `${dispatchBase}/${encode(fixture.dispatchNo)}/items`)
check('Received pool source is present in the one native main without early outbound/delivery', nativeItems.items.some(row => id(row.customerId) === order.customerId
&& row.inboundCode === incomingNo && id(row.warehousePalletId) === id(pallet.warehousePalletId) && Number(row.pickedPackages) === 0))
return { customerId: order.customerId, bookingNo: order.bookingNo, businessOrderNo: order.businessOrderNo, incomingNo, dispatchNo: dispatch.dispatchNo,
outboundNo: converted.outboundNo, deliveryNo: converted.deliveries[0].deliveryNo, palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo }
palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo }
}
export async function runPoolAcceptance(config, { env = process.env, outputRoot = ROOT, runner = runCrossCustomer } = {}) {
if (!config.execute) return poolPlan(config)
const credentials = new Map(); let restoreWrites
try {
const result = await runner(config, { env, outputRoot,
const result = await runner(config, { env, outputRoot, verificationScope: 'CAPACITY_POOL',
contextFactory: options => {
const context = new AcceptanceRun(options), original = context.request
context.request = async function (...args) {