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
@@ -1,10 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs'
import { createFleetCrossCustomerFixture, preparationContract, safeFixtureCount } from './fleet-cross-customer-fixture.mjs'
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too.
function model(fault = '') {
function model(fault = '', countPatch = {}) {
const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = []
const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' }
const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30,
@@ -23,11 +23,10 @@ function model(fault = '') {
ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo,
beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] }
}
const cargo = () => trip.dispatches.map(binding => {
const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d)
return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`,
destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 }
})
let native
const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId,
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
destination: { key: 'FBA:FBA' } }))
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
@@ -58,7 +57,7 @@ function model(fault = '') {
customers.push(customer)
return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password })
}
if (path.endsWith('/submit-review')) return response({ status: 'PENDING' })
if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' })
if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id })))
if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' })
if (path.endsWith('/approve')) return response({})
@@ -108,43 +107,44 @@ function model(fault = '') {
if (path.endsWith('/dispatches/candidates')) {
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo,
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }])
return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId,
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' },
sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] })
}
if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` })
if (path.endsWith('/draft-locks/lock')) return response(null)
if (path.endsWith('/pre-arrange')) {
const n = Number(body.planPalletIds[0].slice(-1))
const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }],
packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 }
dispatches.push(dispatch); return response(dispatch)
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
assert.equal(body.cargo.length, 2); assert.equal(body.warehouseId, '10')
assert.deepEqual(body.cargo.map(row => row.planPalletId), ['PLAN0', 'PLAN1'])
assert.ok(body.cargo.every(row => !Object.hasOwn(row, 'customerId')))
native = { dispatchNo: 'DS-ONE', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: 0,
counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {},
documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} }
trip.dispatches = [{ dispatchNo: native.dispatchNo }]
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] })
dispatches.push(native); return response(native)
}
if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' })
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) }
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) }
if (path.includes('/fleet/trips/TRIP')) {
if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() })
if (method !== 'GET') {
assert.equal(body.recordVersion, trip.trip.recordVersion)
trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1)
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion,
items: fault === 'missing second cargo' ? cargo().slice(0,1) : cargo() })
if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) {
if (method === 'PUT') {
assert.equal(body.recordVersion, native.recordVersion)
Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId })
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } }
native.recordVersion++
} else if (method === 'POST') {
assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++
if (path.endsWith('/submit-review')) native.reviewState = 'DISPATCH_PENDING'
if (path.endsWith('/dispatch-review')) { native.reviewState = 'LOADING_PENDING'; native.reviews.dispatchReview = { status: 'APPROVED' } }
if (path.endsWith('/loading-review')) { native.reviewState = 'APPROVED'; native.reviews.loadingReview = { status: 'APPROVED' }; native.stage = 'LOADING' }
}
if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId })
else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } })
else if (path.endsWith('/plan')) trip.trip.stops = body.stops
else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED'
return response(trip)
const detail = copy(native)
if (fault === 'foreign delivery') detail.documents.deliveries = [{ customerId: 'OTHER' }]
return response(detail)
}
const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n]
const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' }
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] })
if (path.endsWith('/transfer-delivery')) {
assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30)
return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 })
}
if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) }
if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] })
if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch)
throw new Error(`Unexpected offline request ${method} ${path}`)
},
}
@@ -180,9 +180,9 @@ test('real helpers build independent current-run customers, receipts and one com
assert.notEqual(m.customers[0].marker, m.customers[1].marker)
const proof = m.context.createdResources.fleetCrossCustomerFixture
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2)
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2)
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1)
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0)
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
const persisted = JSON.stringify(m.context.createdResources)
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
@@ -211,3 +211,31 @@ test('missing explicit warehouse or caller PDA fails before reading or writing A
assert.equal(m.calls.length, 0)
}
})
test('native Jackson Long count strings build the same complete one-main fixture', async () => {
const m = model('', { packageCount: '2', pickedPackageCount: '0', shippedPackageCount: '0' })
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
assert.equal(fixture.dispatchNo, 'DS-ONE')
assert.equal(fixture.tripNo, 'TRIP')
assert.equal(fixture.members.length, 2)
assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo))
})
test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => {
for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) {
assert.equal(safeFixtureCount(value), expected)
}
for (const value of [null, undefined, true, false, '', ' ', ' 2 ', '02', '+2', '-0', '-1', -1, 1.5, '1.5', '1e2', '2E0', NaN, Infinity, {}, [], Number.MAX_SAFE_INTEGER + 1, '9007199254740992']) {
assert.throws(() => safeFixtureCount(value), /非负安全整数/)
}
})
test('invalid created main counts fail before resource preparation or picking', async () => {
for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) {
const m = model('', { [field]: field === 'packageCount' ? '2e0' : false })
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /非负安全整数/)
assert.equal(m.calls.some(row => row.raw.endsWith('/master-data/vehicles')), false)
assert.equal(m.calls.some(row => row.raw.endsWith('/pick')), false)
}
})