Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs
2026-10-20 15:48:38 +08:00

307 lines
28 KiB
JavaScript

import { createHash } from 'node:crypto'
import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip } from './fleet-trip-fulfillment.mjs'
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
const MODULE = 'fleet-cross-customer-fixture'
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']
const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages']
const testImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
// LocalDateTime API fields use the runner's local wall clock, with no misleading UTC suffix.
const localTime = delay => {
const date = new Date(Date.now() + delay)
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
}
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; 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.',
})
/** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */
export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds,
shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) {
if (!execute) return preparationContract
if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function'
|| ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function'))
throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader')
const memberDestinations = destinationWarehouseIds ?? [destinationWarehouseId, destinationWarehouseId]
if (shipmentReferences !== undefined && (!Array.isArray(shipmentReferences) || shipmentReferences.length !== 2
|| shipmentReferences.some(value => typeof value !== 'string' || !value.trim()))) throw Error('Two explicit TEST shipment references required')
if (prepareBatchAllocations !== undefined && typeof prepareBatchAllocations !== 'function') throw Error('Appointment preparation must be callable')
if (!Array.isArray(memberDestinations) || memberDestinations.length !== 2
|| memberDestinations.some(value => !id(value)) || id(memberDestinations[0]) !== id(destinationWarehouseId))
throw new Error('Two explicit member destinations must start with the primary destination')
context.createdResources ??= {}
if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomerFixture = {
marker: context.marker, warehouseId: id(warehouseId), destinationWarehouseId: id(destinationWarehouseId), status: 'PREPARING',
members: [], requests: [], sessionCleanup: [], lifecycle: preparationContract.lifecycle,
}
const sessions = []
const check = (label, passed) => {
context.assert(MODULE, label, null, Boolean(passed), '', OPTIONS)
if (!passed) throw new Error(label)
}
const key = label => createHash('sha256').update(`${context.marker}:CROSS-FIXTURE:${label}`).digest('hex')
const record = (method, path, body, response, label) => {
// Auth payloads contain credentials; never persist them or response tokens.
proof.requests.push({ method, path, body: path.startsWith('/api/auth/') ? undefined : body, label,
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
check(label, ok(response) && response.requestId && response.traceId)
return response.body.data
}
const request = async (method, path, body, label, token = context.token) =>
record(method, path, body, await context.request(method, path, body, token), label)
const own = member => new URLSearchParams({ customerId: member.customerId })
const stock = async member => {
const query = new URLSearchParams({ customerId: member.customerId, overseasWarehouseId: id(warehouseId),
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 && 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')
const sourceMatches = row => id(row.customerId) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId)
&& row.inboundCode === member.incomingNo
check('Receipt raw balance and position belong to exactly this customer and physical pallet', balance && sourceMatches(balance)
&& id(balance.id) === id(grouped.records[0].id) && positions?.length === 1 && id(positions[0].id) && sourceMatches(positions[0])
&& id(positions[0].balanceId) === id(balance.id) && id(positions[0].palletId) === member.palletId && positions[0].palletNo === member.palletNo
&& balanceBuckets.every(field => String(balance[field]) === (field === 'availablePackages' ? '1' : '0'))
&& buckets.every(field => String(positions[0][field]) === (field === 'availablePackages' ? '1' : '0')))
const movement = ledger.records[0]
check('Exactly one immutable STOCK_IN movement proves the real receipt', sourceMatches(movement) && id(movement.id)
&& movement.businessType === 'STOCK_IN' && movement.businessOrderNo === member.incomingNo
&& movement.palletNo === member.palletNo
&& String(movement.beforePackages) === '0' && String(movement.deltaPackages) === '1' && String(movement.afterPackages) === '1')
// InventoryLedgerView intentionally exposes no balanceId/positionId. Only the complete raw reader proves those links.
const scope = { marker: context.marker, customerId: member.customerId, warehouseId: id(warehouseId) }
const raw = await readInventory(scope)
check('Raw receipt reader proves complete current customer and warehouse scope', raw?.scope?.marker === scope.marker
&& raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId
&& raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE')
for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`,
Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id)
&& id(raw[collection][0].customerId) === member.customerId && id(raw[collection][0].overseasWarehouseId) === id(warehouseId)
&& (collection === 'positions' || raw[collection][0].inboundCode === member.incomingNo))
const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0]
check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id)
&& id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id)
&& id(rawPosition.palletId) === member.palletId && rawPosition.palletNo === member.palletNo
&& balanceBuckets.every(field => String(rawBalance[field]) === String(balance[field]))
&& buckets.every(field => String(rawPosition[field]) === String(positions[0][field]))
&& id(rawMovement.id) === id(movement.id) && id(rawMovement.balanceId) === id(balance.id) && id(rawMovement.positionId) === id(positions[0].id)
&& rawMovement.businessType === 'STOCK_IN' && rawMovement.businessOrderNo === member.incomingNo
&& rawMovement.palletNo === member.palletNo && ['beforePackages', 'deltaPackages', 'afterPackages'].every(field => String(rawMovement[field]) === String(movement[field])))
return { balance, positions, ledger: ledger.records, raw }
}
let fixture, failure
try {
// Explicit IDs are re-read through the public master-data API before any fixture writes.
const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse')
const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId)
&& id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type
&& detail.warehouse.warehouseCode && detail.warehouse.warehouseName
check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS')
&& origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true))
const destinations = new Map()
for (const destinationId of [...new Set(memberDestinations.map(id))]) {
const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationId)}?tenantId=0`, undefined, 'Verify FBA destination')
check('Each explicit destination is a public active FBA master', validMaster(destination, destinationId, 'FBA'))
const address = destination.addresses?.find(row => row.addressLine1)
check('FBA destination has a real address', Boolean(address))
destinations.set(destinationId, { destination, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.district,
address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') })
}
for (const suffix of ['A', 'B']) {
const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1])
const { destination, deliveryAddress } = destinations.get(memberDestinationId)
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false }
proof.members.push(member)
// Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot.
const child = Object.create(context)
child.marker = member.customerMarker
child.options = { ...context.options, customerName: `跨客户车次-${key(suffix).slice(0, 12)}-${suffix}` }
child.context = {}; child.createdResources = {}
child.request = async (method, path, body, token = context.token) => {
const response = await context.request(method, path, body, token)
record(method, path, body, response, `Qualify fresh customer ${suffix}`)
if (method === 'POST' && path === '/api/platform/v1/iam/tenants')
member.customerId = id((response.body.data?.tenant || response.body.data?.customer || response.body.data)?.id || response.body.data?.tenantId)
return response
}
await createQualifiedMerchant(child, { publishEnvironment: false })
const credentials = child.context.merchantQualificationFixture
check('Qualification belongs to this independent current-run context', credentials?.marker === member.customerMarker
&& id(credentials.tenantId) === member.customerId && proof.members.filter(row => row.customerId === member.customerId).length === 1)
member.qualification = child.createdResources.merchantQualificationFixture
let auth
try {
auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login fresh customer ${suffix}`, '')
} finally { delete child.context.merchantQualificationFixture }
// Retain a returned session for cleanup even if the login contract is incomplete.
if (auth?.accessToken) sessions.push({ customerId: member.customerId, accessToken: auth.accessToken, refreshToken: auth.refreshToken })
check('Fresh customer session is complete', auth?.accessToken && auth.refreshToken)
const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken)
check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId)
const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId),
fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: memberDestinationId,
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
if (shipmentReferences) createBody.fbaShipmentNo = shipmentReferences[proof.members.length - 1]
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
member.incomingNo = incoming?.transferNo
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
&& incoming.status === 'DRAFT' && id(incoming.operatingWarehouseId) === id(warehouseId))
const merchantPath = `${MERCHANT}/${encode(member.incomingNo)}`
incoming = await request('POST', `${merchantPath}/submit`, undefined, `Submit own incoming ${suffix}`, auth.accessToken)
check('Own incoming submitted for review', incoming?.status === 'PENDING_REVIEW')
incoming = await request('POST', `${ROOT}/warehouse-transfers/${encode(member.incomingNo)}/review?${own(member)}`,
{ decision: 'APPROVE', remark: member.customerMarker }, `Approve incoming ${suffix}`)
check('Approved fresh incoming awaits real PDA receipt', incoming?.status === 'WAIT_PDA_RECEIVING')
const pdaPath = `/api/pda/v1/last-leg/warehouse-transfers/${encode(member.incomingNo)}`
const task = await request('GET', pdaPath, undefined, 'PDA warehouse binding resolves exact new incoming', pdaToken)
check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo
&& task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId)
&& task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0)
const forecastItem = task.items?.[0], forecastCarton = task.cartons?.[0]
check('One original forecast carton and declared SKU belong to this fresh one-box incoming', safeFixtureCount(task.packageCount) === 1
&& task.items?.length === 1 && forecastItem?.id && forecastItem.skuCode === createBody.items[0].skuCode
&& safeFixtureCount(forecastItem.packageCount) === 1 && safeFixtureCount(forecastItem.quantity) === 1
&& task.cartons?.length === 1 && forecastCarton?.cartonMark
&& (forecastCarton.transferItemId == null || id(forecastCarton.transferItemId) === id(forecastItem.id)))
// PalletDraft creates only an empty identity; measurements belong to the real carton-count command below.
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`) }, 'Create empty TEST pallet draft', pdaToken)
check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT')
const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}`
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken)
const pallet = generated?.pallets?.[0]
check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
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 generated empty physical pallet before carton counting', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
&& id(bound.pallets[0].id) === id(measured.pallets[0].id)
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 0)
const counted = await request('POST', `${pdaPath}/cartons/count`, { idempotencyKey: key(`COUNT-${suffix}`),
forecastPackageCount: safeFixtureCount(task.packageCount), boxes: [{ cartonMark: forecastCarton.cartonMark,
transferItemId: id(forecastItem.id), transferPalletId: id(measured.pallets[0].id),
length: 100, width: 100, height: 100, weight: 100,
items: [{ skuCode: forecastItem.skuCode, quantity: safeFixtureCount(forecastItem.quantity) }] }] },
'Count original TEST carton, record dimensions/SKU and allocate it to the bound pallet', pdaToken)
const countedCarton = counted?.cartons?.[0], countedPallet = counted?.pallets?.[0]
check('Actual carton count proves one declared box/SKU on the original bound pallet', counted?.transferNo === member.incomingNo
&& counted.cartons?.length === 1 && countedCarton.cartonMark === forecastCarton.cartonMark
&& countedCarton.status === 'COUNTED' && countedCarton.countedAt
&& id(countedCarton.transferPalletId) === id(measured.pallets[0].id)
&& id(countedCarton.transferItemId) === id(forecastItem.id)
&& ['length', 'width', 'height', 'weight'].every(field => Number(countedCarton[field]) === 100)
&& safeFixtureCount(countedCarton.quantity) === 1 && countedCarton.items?.length === 1
&& countedCarton.items[0].skuCode === forecastItem.skuCode
&& id(countedCarton.items[0].transferItemId) === id(forecastItem.id) && safeFixtureCount(countedCarton.items[0].quantity) === 1
&& counted.pallets?.length === 1 && countedPallet.status === 'BOUND' && id(countedPallet.id) === id(measured.pallets[0].id)
&& id(countedPallet.warehousePalletId) === member.palletId && safeFixtureCount(countedPallet.cartonCount) === 1
&& Number(countedPallet.grossWeightKg) === 100 && Number(countedPallet.volumeCbm) === 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 && 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)
incoming = await request('POST', `${merchantPath}/confirm-measurement`, { idempotencyKey: key(`CONFIRM-${suffix}`), remark: member.customerMarker }, 'Customer confirms measured incoming', auth.accessToken)
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
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, countedCarton,
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'])
check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
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: [] }
if (prepareBatchAllocations) {
proof.createDispatchRequest.batchAllocations = await prepareBatchAllocations(proof.members)
if (!Array.isArray(proof.createDispatchRequest.batchAllocations)) throw Error('Formal appointment allocations must be explicit')
for (const member of proof.members) {
const current = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
undefined, 'Refresh actual candidate after formal appointment creation')
const matches = current.candidates?.filter(row => id(row.customerId) === member.customerId && id(row.selection?.warehousePalletId) === member.palletId)
check('Appointment preparation preserves exact received physical source', matches?.length === 1 && current.complete === true
&& matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].blockReasons?.length === 0)
member.selection = matches[0].selection
}
proof.createDispatchRequest.cargo = proof.members.map(member => member.selection)
await context.checkpoint?.()
}
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 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))
}
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 = []
for (const session of sessions) {
try {
await request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, 'Logout owned merchant fixture session', session.accessToken)
proof.sessionCleanup.push({ customerId: session.customerId, status: 'LOGGED_OUT' })
} catch (error) {
cleanupErrors.push(error); proof.sessionCleanup.push({ customerId: session.customerId, status: 'FAILED' })
}
}
proof.status = failure || cleanupErrors.length ? 'FAILED' : 'READY'
if (failure && !cleanupErrors.length) throw failure
if (cleanupErrors.length) throw new AggregateError([...(failure ? [failure] : []), ...cleanupErrors], 'Fixture/session cleanup failed; retain recorded resources')
return fixture
}