313 lines
29 KiB
JavaScript
313 lines
29 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { run as createQualifiedMerchant, normalizeFixedCustomerId, requireFixedSecondaryCustomer } 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: 'TEST/GRAY reuse two designated existing ACTIVE customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and configured reusable vehicle/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?.options?.fixedCustomerId) throw Error('跨客户验收必须配置两个既有固定客户,禁止自动建客')
|
|
requireFixedSecondaryCustomer(context.options)
|
|
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(`Complete customer raw ${collection} preserves scoped unique identities`,
|
|
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) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId)))
|
|
const currentBalances = raw.balances.filter(row => row.inboundCode === member.incomingNo)
|
|
const currentPositions = raw.positions.filter(row => currentBalances.some(balance => id(balance.id) === id(row.balanceId)))
|
|
const currentMovements = raw.ledger.filter(row => row.inboundCode === member.incomingNo && row.businessOrderNo === member.incomingNo)
|
|
check('Current receipt has exactly one original raw balance position and movement', currentBalances.length === 1 && currentPositions.length === 1 && currentMovements.length === 1)
|
|
const rawBalance = currentBalances[0], rawPosition = currentPositions[0], rawMovement = currentMovements[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 configuredCustomer = normalizeFixedCustomerId(suffix === 'A' ? context.options.fixedCustomerId : context.options.fixedSecondaryCustomer.customerId)
|
|
const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false,
|
|
...(configuredCustomer ? { customerId: configuredCustomer } : {}) }
|
|
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, `Verify customer ${suffix}`)
|
|
return response
|
|
}
|
|
await createQualifiedMerchant(child, { publishEnvironment: false, requireDistinctCustomer: suffix === 'B' })
|
|
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 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('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
|
|
}
|