Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs
T

194 lines
18 KiB
JavaScript
Raw Normal View History

import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs'
const MODULE = 'fleet-cross-customer-acceptance'
const ROOT = '/api/platform/v1/last-leg'
const PDA = '/api/pda/v1/last-leg/dispatches'
const OPTIONS = { scenario: 'fleet-cross-customer-departure', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const ok = response => response?.http === 200 && id(response.body?.code) === '200'
const encode = encodeURIComponent
const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}`
const canonical = value => JSON.stringify(normalize(value))
function normalize(value) {
// Route stops and other business arrays are ordered. Raw readers return stable ID order.
if (Array.isArray(value)) return value.map(normalize)
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value
}
function integer(value) {
if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer')
return Number(value)
}
const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']
const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages']
export const fixtureContract = Object.freeze({
stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED',
fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}',
preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.',
reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.',
helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.',
boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.',
})
/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution.
* Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys.
*/
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory } = {}) {
if (!execute) return fixtureContract
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage
|| !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function'
|| fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2
|| fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true
|| ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) {
throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader')
}
for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) {
if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`)
}
context.createdResources ||= {}
if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo,
warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})),
status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' }
const check = (name, passed) => {
context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS)
if (!passed) throw new Error(name)
}
const key = suffix => `${context.marker}-CROSS-${suffix}`
const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}`
const request = async (method,path,body,label,pda=false,rejection=false) => {
const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{})
proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId})
const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409')
check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response)))
return response.body?.data
}
const stock = async member => {
const scope={marker:context.marker,customerId:member.customerId,warehouseId:fixture.warehouseId}
const raw=await readInventory(scope)
check('Inventory reader proves complete customer/warehouse scope',canonical(raw?.scope)===canonical({...scope,coverage:'CUSTOMER_WAREHOUSE_COMPLETE'}))
for(const collection of ['balances','positions','ledger'])check(`Complete ${collection} rows have unique identities and customer ownership`,Array.isArray(raw[collection])
&& new Set(raw[collection].map(row=>id(row.id))).size===raw[collection].length
&& raw[collection].every(row=>id(row.id) && id(row.customerId)===id(member.customerId) && id(row.overseasWarehouseId)===id(fixture.warehouseId)))
check('Original receipt balance and physical position are retained even at zero',raw.balances.some(row=>id(row.id)===id(member.balanceId))
&& raw.positions.some(row=>id(row.id)===id(member.positionId) && id(row.balanceId)===id(member.balanceId) && id(row.palletId)===id(member.palletId) && row.palletNo===member.palletNo))
for(const balance of raw.balances) {
for(const field of balanceBuckets) integer(balance[field])
const positions=raw.positions.filter(row=>id(row.balanceId)===id(balance.id))
for(const field of buckets) check(`Raw balance and every position conserve ${field}`,integer(balance[field])===positions.reduce((sum,row)=>sum+integer(row[field]),0))
}
check('No orphan stock positions',raw.positions.every(row=>raw.balances.some(balance=>id(balance.id)===id(row.balanceId))))
return raw
}
const snapshot = async label => {
const trip=await request('GET',tripPath,undefined,'Read shared trip')
check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId)
&& trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId))))
const members=[]
for(const member of fixture.members) {
const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch')
const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound')
const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery')
const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true)
check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo
&& id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1
&& id(dispatch.items[0].warehousePalletId)===id(member.palletId)
&& outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo
&& id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1
&& outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId)
&& delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId)
&& delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo
&& task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo)
members.push({dispatch,outbound,delivery,task,stock:await stock(member)})
}
const value={trip,members};proof.snapshots[label]=value;return value
}
const equal = (a,b,name)=>check(name,canonical(a)===canonical(b))
const pick = async (member,index) => {
const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task
check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1
&& integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1
&& id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1)
const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker}
const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick`
await request('POST',path,body,'Pick this customer only',true)
await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true)
const after=await snapshot(`AFTER_PICK_${index}`)
equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking')
const expected=structuredClone(before.members[index].stock)
for(const collection of ['balances','positions']) {
const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId))
row.availablePackages=integer(row.availablePackages)-1
}
const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))]))
equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box')
const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id)))
equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved')
check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type
&& id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1))
await request('POST',path,body,'Replay exact customer pick',true)
equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record')
}
try {
let initial=await snapshot('INITIAL')
check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED'
&& initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1
&& integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY'
&& integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1
&& row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0))
&& row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1))
let detail=initial.trip
for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`,
{recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately')
check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED'))
await pick(fixture.members[0],0)
const rejectedBefore=await snapshot('GATE_BEFORE')
check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0)
await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`,
{recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true,
message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数'))
equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
proof.gateProof='PREREQUISITE_REJECTION_VERIFIED'
await pick(fixture.members[1],1)
const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading')
check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2)
detail=await request('GET',tripPath,undefined,'Read current trip route')
const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops)
const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements')
const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading')
const ready=await snapshot('READY')
const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker}
const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`
const departed=await request('POST',departPath,departBody,'Depart whole trip once',true)
check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2
&& canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort()))
const after=await snapshot('DEPARTED')
check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED'))
equal(ready.members.map(row=>row.stock),after.members.map(row=>row.stock),'Departure does not deduct either inventory again')
const replay=await request('POST',departPath,departBody,'Replay exact shared departure',true)
check('Shared departure replay is explicit',replay.replayed===true)
equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts')
for(const [index,member] of fixture.members.entries()) {
const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId)
await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId),
{status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery')
const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId)
const docs=await request('GET',documentPath,undefined,'Read this delivery document version')
const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),
{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)})
proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId})
check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId))
const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)}))
documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]})
await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery')
const signed=await snapshot(`SIGNED_${index}`)
check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED')
equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly')
equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory')
// Retain the independently readable result as evidence, not a fabricated completion command.
await request('GET',path,undefined,'Read signed customer delivery')
}
proof.status='VERIFIED';return proof
} catch(error) {proof.status='FAILED';proof.error=error.message;throw error}
}