feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution
This commit is contained in:
@@ -8,6 +8,11 @@ const ROOT = '/api/platform/v1/last-leg'
|
||||
const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
export function safeFixtureCount(value) {
|
||||
if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9]\d*)$/.test(String(value))
|
||||
|| !Number.isSafeInteger(Number(value))) throw new Error('原生夹具数量须为非负安全整数')
|
||||
return Number(value)
|
||||
}
|
||||
const encode = encodeURIComponent
|
||||
const ok = response => response?.http === 200 && id(response.body?.code) === '200'
|
||||
const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
|
||||
@@ -22,7 +27,7 @@ const localTime = delay => {
|
||||
export const preparationContract = Object.freeze({
|
||||
stage: fixtureContract.stage,
|
||||
inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope',
|
||||
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.',
|
||||
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.',
|
||||
lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.',
|
||||
})
|
||||
|
||||
@@ -59,8 +64,8 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
inboundCode: member.incomingNo, page: '1', size: '200' })
|
||||
const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page')
|
||||
const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page')
|
||||
check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1
|
||||
&& ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id))
|
||||
check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && safeFixtureCount(grouped.total) === 1
|
||||
&& ledger?.records?.length === 1 && safeFixtureCount(ledger.total) === 1 && id(grouped.records[0].id))
|
||||
const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}`
|
||||
const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity')
|
||||
const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position')
|
||||
@@ -172,11 +177,11 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo
|
||||
const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken)
|
||||
check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1)
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 1)
|
||||
const photoPath = `${pdaPath}/measurement-photos/files`
|
||||
const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath,
|
||||
{ name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence')
|
||||
check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0)
|
||||
check('Measurement evidence is a stored file', photo?.fileId && safeFixtureCount(photo.sizeBytes) > 0)
|
||||
incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken)
|
||||
check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION'
|
||||
&& incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId)
|
||||
@@ -184,73 +189,51 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH')
|
||||
member.receipt = await stock(member)
|
||||
member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true
|
||||
const dispatchBase = `${ROOT}/dispatches`
|
||||
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate')
|
||||
check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1
|
||||
&& id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId)
|
||||
&& candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId
|
||||
&& candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId)
|
||||
member.draftToken = key(`DRAFT-${suffix}`)
|
||||
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft')
|
||||
check('Dispatch draft has explicit container identity', container?.id)
|
||||
member.draftContainerId = id(container.id)
|
||||
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken,
|
||||
draftContainerId: member.draftContainerId }, 'Lock this customer received pallet')
|
||||
const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch')
|
||||
check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE'
|
||||
&& dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId
|
||||
&& Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
|
||||
member.dispatchNo = dispatch.dispatchNo
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true,
|
||||
sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) }
|
||||
const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
|
||||
undefined, 'Read own native received source selection')
|
||||
check('Candidate list is complete and has exact customer, incoming, warehouse and physical pallet ownership', candidates?.complete === true
|
||||
&& candidates.candidates?.length === 1 && id(candidates.candidates[0].customerId) === member.customerId
|
||||
&& id(candidates.candidates[0].warehouseId) === id(warehouseId) && candidates.candidates[0].inboundCode === member.incomingNo
|
||||
&& candidates.candidates[0].palletNo === member.palletNo && safeFixtureCount(candidates.candidates[0].availablePackages) === 1
|
||||
&& candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN' && candidates.candidates[0].selection.planPalletId
|
||||
&& candidates.candidates[0].sourceVersion && candidates.candidates[0].selection.sourceVersion === candidates.candidates[0].sourceVersion
|
||||
&& candidates.candidates[0].blockReasons?.length === 0)
|
||||
member.selection = candidates.candidates[0].selection
|
||||
member.destination = candidates.candidates[0].destination
|
||||
}
|
||||
for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo'])
|
||||
for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId'])
|
||||
check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId))
|
||||
proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId),
|
||||
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null,
|
||||
remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] }
|
||||
const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers')
|
||||
check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE'
|
||||
&& created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0)
|
||||
proof.dispatchNo = created.dispatchNo
|
||||
for (const member of proof.members) member.dispatchNo = proof.dispatchNo
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, proof.dispatchNo, id(warehouseId))
|
||||
proof.tripNo = tripProof.tripNo
|
||||
const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}`
|
||||
let detail = await request('GET', tripPath, undefined, 'Read first member trip version')
|
||||
detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion,
|
||||
dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch')
|
||||
const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations')
|
||||
check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member =>
|
||||
loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId
|
||||
&& id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1)))
|
||||
const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }]))
|
||||
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
|
||||
plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt,
|
||||
stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations')
|
||||
tripProof.stops = detail.trip.stops
|
||||
check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey)))
|
||||
for (const [index, member] of proof.members.entries()) {
|
||||
const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}`
|
||||
const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote')
|
||||
check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0)
|
||||
const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000),
|
||||
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
|
||||
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
|
||||
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery')
|
||||
check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo
|
||||
&& transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1)
|
||||
member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo
|
||||
const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery')
|
||||
const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound')
|
||||
const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked')
|
||||
check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId
|
||||
&& delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo
|
||||
&& delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo
|
||||
&& id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo
|
||||
&& dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
|
||||
const finalStock = await stock(member)
|
||||
check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt))
|
||||
const dispatchPath = `${ROOT}/dispatches/${encode(proof.dispatchNo)}`
|
||||
const detail = await request('GET', dispatchPath, undefined, 'Read one reviewed native cross-customer dispatch')
|
||||
const items = await request('GET', `${dispatchPath}/items`, undefined, 'Read native per-customer cargo identities')
|
||||
check('Both actual customer pallets share one reviewed native dispatch before any picking', detail.dispatchNo === proof.dispatchNo
|
||||
&& detail.status === 'ACTIVE' && detail.stage === 'LOADING' && detail.reviewState === 'APPROVED'
|
||||
&& safeFixtureCount(detail.counts?.packageCount) === 2 && safeFixtureCount(detail.counts?.pickedPackageCount) === 0 && safeFixtureCount(detail.counts?.shippedPackageCount) === 0
|
||||
&& detail.documents?.outbounds?.length === 0 && detail.documents?.deliveries?.length === 0
|
||||
&& items.dispatchNo === proof.dispatchNo && id(items.recordVersion) === id(detail.recordVersion) && items.items?.length === 2
|
||||
&& proof.members.every(member => items.items.some(row => id(row.customerId) === member.customerId
|
||||
&& id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destination?.key === member.destination?.key
|
||||
&& safeFixtureCount(row.scheduledPackages) === 1 && safeFixtureCount(row.pickedPackages) === 0 && row.dispatchItemId)))
|
||||
for (const member of proof.members) {
|
||||
member.dispatchItemId = id(items.items.find(row => id(row.customerId) === member.customerId).dispatchItemId)
|
||||
check('Trip preparation preserves original available receipt records', JSON.stringify(await stock(member)) === JSON.stringify(member.receipt))
|
||||
}
|
||||
detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership')
|
||||
check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2
|
||||
&& proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId)))
|
||||
for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
|
||||
fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken,
|
||||
members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) =>
|
||||
({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) }
|
||||
fixture = { marker: context.marker, stage: fixtureContract.stage, dispatchNo: proof.dispatchNo, tripNo: proof.tripNo,
|
||||
warehouseId: id(warehouseId), pdaToken, tripProof,
|
||||
members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination }) =>
|
||||
({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination })) }
|
||||
} catch (error) { failure = error }
|
||||
// Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller.
|
||||
const cleanupErrors = []
|
||||
|
||||
Reference in New Issue
Block a user