976 lines
78 KiB
JavaScript
976 lines
78 KiB
JavaScript
import assert from 'node:assert/strict'
|
||||
|
|
import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|||
|
|
import { dirname, resolve } from 'node:path'
|
|||
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|||
|
|
import { createHash } from 'node:crypto'
|
|||
|
|
import { stableSnapshot } from './admin-test-env-modules/container-customs-acceptance-matrix.mjs'
|
|||
|
|
|
|||
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|||
|
|
const PDA = '/api/platform/v1/first-leg/pda-loading'
|
|||
|
|
const LEG = '/api/platform/v1/first-leg'
|
|||
|
|
const CUSTOMS = '/api/platform/v1/customs/loading-plans'
|
|||
|
|
const TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
|
|||
|
|
const id = value => String(value ?? '')
|
|||
|
|
const positive = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
|
|||
|
|
const rows = value => Array.isArray(value) ? value : []
|
|||
|
|
const sha = value => createHash('sha256').update(value).digest('hex')
|
|||
|
|
class CheckFailure extends Error {}
|
|||
|
|
function need(condition, message) { if (!condition) throw new CheckFailure(message) }
|
|||
|
|
|
|||
|
|
export function plan() {
|
|||
|
|
return { plan: true, networkRequests: 0, businessWrites: false,
|
|||
|
|
cases: ['A: refund PARTIAL 3→2 + same-owner SKIP + buy PICK + skipped refund customer',
|
|||
|
|
'B: only actual buy row PARTIAL 2→1 + all refund rows SKIP; all actual rows PARTIAL'],
|
|||
|
|
upstream: 'Use --fixture=NEW to prepare isolated customers/orders/measurements and two confirmed cabinets, or consume two passing manual-pda-batch-fixture reports',
|
|||
|
|
operations: ['validate both unclaimed tasks before writes', 'claim', 'one atomic batch per task',
|
|||
|
|
'upload five supplied image files', 'complete and poll', 'actual list/metrics/customs scope',
|
|||
|
|
'same original completion replay and zero drift', 'all-buy start/complete and refund-send rejection', 'logout own sessions'],
|
|||
|
|
excluded: ['F01–F40 full regression', 'SQL/deployment', 'historical events or data repair',
|
|||
|
|
'browser/device proof', 'Outbox/ACK/downstream inventory evidence', 'refund document workflow and XLSX download'],
|
|||
|
|
manifest: { schemaVersion: 1, runId: 'PARTIAL-ACTUAL-<new-run>', environment: 'TEST',
|
|||
|
|
cases: ['A', 'B'].map(kind => ({ kind, sourceReport: '<new successful Admin fixture report>',
|
|||
|
|
actions: [{ itemId: '<PDA item id from taskItems>', action: 'PARTIAL', finalPickedPackageCount: kind === 'A' ? 2 : 1 }],
|
|||
|
|
evidence: Object.fromEntries(TYPES.map(type => [type, '<supplied PNG or JPEG path>'])), sealNo: '<nonempty seal>' })) },
|
|||
|
|
execution: 'node --env-file=<authorized TEST test-acceptance.env> scripts/partial-loading-actual-test-env.mjs --execute --environment=TEST --run-id=<new-run> --fixture=<manifest.json>' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function executionOptions(args, execArgs, env, cwd = process.cwd()) {
|
|||
|
|
if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false }
|
|||
|
|
need([4,5].includes(args.length) && args[0] === '--execute' && args[1] === '--environment=TEST'
|
|||
|
|
&& /^--run-id=PARTIAL-ACTUAL-[A-Za-z0-9_-]{1,40}$/.test(args[2])
|
|||
|
|
&& args[3].startsWith('--fixture=') && args[3].slice(10), 'Explicit TEST, new PARTIAL-ACTUAL run-id and fixture required')
|
|||
|
|
need(resolve(cwd).replaceAll('\\', '/').toLowerCase() === ROOT.replaceAll('\\', '/').toLowerCase(), 'Run in the TEST backend workspace')
|
|||
|
|
need(execArgs.length === 1 && execArgs[0].startsWith('--env-file=')
|
|||
|
|
&& resolve(execArgs[0].slice(11)).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the authorized TEST env-file')
|
|||
|
|
need(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required')
|
|||
|
|
for (const key of ['CHONGCHONG_ADMIN_API_BASE_URL', 'CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) need(Boolean(env[key]), `Missing ${key}`)
|
|||
|
|
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
|
|||
|
|
need(['https:', 'http:'].includes(base.protocol) && !base.username && !base.password
|
|||
|
|
&& base.pathname === '/api' && !base.search && !base.hash, 'Configured TEST admin API base must end in /api')
|
|||
|
|
const input = args[3].slice(10)
|
|||
|
|
const completion = input.startsWith('COMPLETION:') || input.startsWith('VERIFY_A:') || input.startsWith('B_ONLY:')
|
|||
|
|
need(completion ? args[4] === '--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae' : args.length === 4,
|
|||
|
|
'Original-command recovery requires explicit matching controller-ready commit')
|
|||
|
|
return { execute: true, runId: args[2].slice(9), fixture: input === 'NEW' ? 'NEW' : input.startsWith('RESUME:')
|
|||
|
|
? { resumeReport: resolve(input.slice(7)) } : input.startsWith('PREPARED:')
|
|||
|
|
? { preparedReport: resolve(input.slice(9)) } : input.startsWith('B_ONLY:') ? { bOnlyReport: resolve(input.slice(7)) }
|
|||
|
|
: input.startsWith('VERIFY_A:') ? { verifyAReport: resolve(input.slice(9)) }
|
|||
|
|
: completion ? { completionReport: resolve(input.slice(11)) } : resolve(input),
|
|||
|
|
gateway: base.origin, ...(completion ? { readyCommit: args[4].slice(15) } : {}) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function originalCompletionCommand(failedReport) {
|
|||
|
|
need(failedReport.runId === 'PARTIAL-ACTUAL-20261004-recovery-r7' && failedReport.success === false, 'Bind the original A failed execution')
|
|||
|
|
const attempts = rows(failedReport.requests).filter(item => item.method === 'POST'
|
|||
|
|
&& item.path === `${PDA}/tasks/2106713892758491138/complete`)
|
|||
|
|
need(attempts.length === 1 && attempts[0].state === 'RECEIVED' && attempts[0].http === 200 && attempts[0].code === '200', 'Exactly one accepted original completion command required')
|
|||
|
|
const body = attempts[0].request
|
|||
|
|
assert.deepEqual(body, { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' }, 'Original immutable completion payload')
|
|||
|
|
const command = attempts[0].response?.data?.completionCommand
|
|||
|
|
need(id(command?.id) === '2106716718935052290' && command.operationId === body.operationId && Number(command.expectedTaskVersion) === 2,
|
|||
|
|
'Original command identity/version mismatch')
|
|||
|
|
return { commandId: id(command.id), body: structuredClone(body) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dataOf = response => {
|
|||
|
|
need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`)
|
|||
|
|
return response.body.data
|
|||
|
|
}
|
|||
|
|
const selected = (value, names) => Object.fromEntries(names.map(name => [name, value?.[name] ?? null]))
|
|||
|
|
export function stableSource(source) {
|
|||
|
|
return { summary: selected(source.summary, ['id','tenantId','orderId','orderNo','inboundCode','processStatus','version','packageCount','totalPackageCount','splitStatus','actualGrossWeightKg','actualVolumeCbm']),
|
|||
|
|
statusHistory: source.statusHistory, palletLocations: source.palletLocations }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function sourceSnapshot(spec, request) {
|
|||
|
|
const result = []
|
|||
|
|
for (const owner of spec.owners) result.push({ owner, view: dataOf(await request('GET', `/api/platform/inbounds/${owner.inboundId}?tenantId=${owner.tenantId}`)) })
|
|||
|
|
// Failed/completed tasks are not an editable inventory-snapshot surface. The
|
|||
|
|
// source detail API exposes the persisted block/slot/reservation and allocation
|
|||
|
|
// quantities needed to prove these already-completed owners do not settle twice.
|
|||
|
|
return { sources: result, inventory: { allocations: result.map(item => ({ tenantId: item.owner.tenantId,
|
|||
|
|
inboundId: item.owner.inboundId, palletLocations: item.view.palletLocations })) } }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function verifyOldOwnersUnchanged(before, after, expected) {
|
|||
|
|
for (const item of before.sources) {
|
|||
|
|
const current = after.sources.find(row => row.owner.inboundId === item.owner.inboundId)
|
|||
|
|
need(current, 'Original source disappeared from readback')
|
|||
|
|
assert.equal(stableSnapshot(stableSource(current.view)), stableSnapshot(stableSource(item.view)), 'Original owner facts/version/history/resources must not repeat')
|
|||
|
|
const actual = expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0)
|
|||
|
|
need(actual ? current.view.summary.processStatus === 'LOADED' : current.view.summary.processStatus !== 'LOADED', 'Whole skipped source cannot become LOADED')
|
|||
|
|
}
|
|||
|
|
assert.equal(stableSnapshot(after.inventory), stableSnapshot(before.inventory), 'Original domestic inventory must not be deducted again')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function verifyPhysicalFacts(expected, task) {
|
|||
|
|
const actual = rows(task.items).filter(row => ['PICKED','PARTIAL'].includes(row.itemStatus) && Number(row.pickedPackageCount) > 0)
|
|||
|
|
const expectedActual = expected.filter(row => row.actual > 0)
|
|||
|
|
assert.deepEqual(actual.map(row => id(row.loadingPlanItemId)).sort(), expectedActual.map(row => row.itemId).sort(), 'Original physical item set')
|
|||
|
|
for (const item of expectedActual) {
|
|||
|
|
const row = actual.find(row => id(row.loadingPlanItemId) === item.itemId)
|
|||
|
|
need(Number(row.pickedPackageCount) === item.actual && id(row.tenantId) === item.accountId && id(row.inboundId) === item.inboundId,
|
|||
|
|
'Original physical count/customer/source drift')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function readSmallPage(path, request) {
|
|||
|
|
const page = dataOf(await request('GET', path))
|
|||
|
|
need(Array.isArray(page.records) && Number(page.page) === 1 && Number(page.size) === 200
|
|||
|
|
&& Number(page.total) === page.records.length && new Set(page.records.map(row => id(row.id))).size === page.records.length,
|
|||
|
|
'Bounded new-sample page incomplete or duplicated')
|
|||
|
|
return page.records
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function downstreamSnapshot(spec, request) {
|
|||
|
|
const result = []
|
|||
|
|
for (const owner of spec.owners) {
|
|||
|
|
const query = new URLSearchParams({ customerId: owner.tenantId, containerNo: spec.source.containerNo, page: '1', size: '200' })
|
|||
|
|
const orders = await readSmallPage(`/api/platform/v1/last-leg/orders?${query}`, request)
|
|||
|
|
const code = spec.expected.find(row => row.inboundId === owner.inboundId)?.frozen.inboundCode
|
|||
|
|
need(code, 'Frozen original inbound code required for inventory scope')
|
|||
|
|
const inventory = new URLSearchParams({ customerId: owner.tenantId, inboundCode: code, page: '1', size: '200' })
|
|||
|
|
const groupedBalances = await readSmallPage(`/api/platform/v1/last-leg/inventory/balances?${inventory}`, request)
|
|||
|
|
const balances = []
|
|||
|
|
for (const row of groupedBalances) balances.push(dataOf(await request('GET', `/api/platform/v1/last-leg/inventory/balances/${row.id}?customerId=${owner.tenantId}`)))
|
|||
|
|
const ledger = await readSmallPage(`/api/platform/v1/last-leg/inventory/ledger?${inventory}`, request)
|
|||
|
|
const details = []
|
|||
|
|
for (const order of orders) details.push(dataOf(await request('GET', `/api/platform/v1/last-leg/orders/${order.id}?customerId=${owner.tenantId}`)))
|
|||
|
|
result.push({ owner, orders, balances, groupedBalances, ledger, details })
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function verifyDownstream(spec, snapshot) {
|
|||
|
|
let packages = 0
|
|||
|
|
for (const item of snapshot) {
|
|||
|
|
const actualRows = spec.expected.filter(row => row.inboundId === item.owner.inboundId && row.actual > 0)
|
|||
|
|
const expected = actualRows.reduce((sum, row) => sum + row.actual, 0)
|
|||
|
|
if (!expected) {
|
|||
|
|
need(!item.orders.length && !item.balances.length && !item.ledger.length, 'Skipped owner must have no tail cargo or transit inventory')
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
need(item.orders.length === 1 && item.details.length === 1, 'Exactly one consumer-created tail order per actual source')
|
|||
|
|
const order = item.orders[0], detail = item.details[0]
|
|||
|
|
need(id(order.customerId) === item.owner.tenantId && id(order.sourceInboundId) === item.owner.inboundId
|
|||
|
|
&& id(order.sourceContainerBookingId) === spec.source.containerBookingId && order.containerNo === spec.source.containerNo
|
|||
|
|
&& Number(order.packageCount) === expected, 'Tail materialization must use exact actual customer/source/container boxes')
|
|||
|
|
need(rows(detail.statusHistory).filter(row => row.toStatus === 'PENDING_ASSIGNMENT' && row.remark === '头程装柜完成自动生成').length === 1,
|
|||
|
|
'Exactly one actual loading-event creation history')
|
|||
|
|
need(rows(detail.cargo).length === actualRows.length, 'Skipped SKU cannot enter frozen tail cargo')
|
|||
|
|
for (const row of actualRows) {
|
|||
|
|
const cargo = detail.cargo.find(cargo => cargo.skuCode === row.frozen.skuCode)
|
|||
|
|
need(cargo && Number(cargo.packageCount) === row.actual, 'Tail SKU actual boxes mismatch')
|
|||
|
|
}
|
|||
|
|
const sum = (values, field) => values.reduce((total, row) => total + Number(row[field]), 0)
|
|||
|
|
need(item.balances.every(row => row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id)
|
|||
|
|
&& Number(row.availablePackages) === 0 && Number(row.holdPackages) === 0 && Number(row.allocatedPackages) === 0
|
|||
|
|
&& Number(row.pickedPackages) === 0 && Number(row.receivingPackages) === 0 && Number(row.procurementPackages) === 0)
|
|||
|
|
&& sum(item.balances, 'inTransitPackages') === expected && sum(item.balances, 'totalPackages') === 0,
|
|||
|
|
'Initial transit inventory must equal actual boxes only')
|
|||
|
|
need(item.ledger.length === actualRows.length && sum(item.ledger, 'deltaPackages') === expected
|
|||
|
|
&& item.ledger.every(row => Number(row.beforePackages) === 0 && Number(row.afterPackages) === Number(row.deltaPackages)
|
|||
|
|
&& row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id) && row.businessOrderNo === order.orderNo),
|
|||
|
|
'One initial inventory movement per actual SKU; no duplicate or skipped movement')
|
|||
|
|
packages += expected
|
|||
|
|
}
|
|||
|
|
return { actualPackages: packages, loadingEventMaterializationVerified: true, directOutboxAckAuditVerified: false }
|
|||
|
|
}
|
|||
|
|
const rowCode = actualRows => actualRows[0].frozen.inboundCode
|
|||
|
|
|
|||
|
|
async function waitDownstream(spec, request, sleep) {
|
|||
|
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|||
|
|
const snapshot = await downstreamSnapshot(spec, request)
|
|||
|
|
const expectedOrders = spec.expected.filter(row => row.actual > 0).map(row => row.inboundId)
|
|||
|
|
const active = [...new Set(expectedOrders)]
|
|||
|
|
if (snapshot.reduce((sum, item) => sum + item.orders.length, 0) === active.length) return { snapshot, facts: verifyDownstream(spec, snapshot) }
|
|||
|
|
await sleep(1000)
|
|||
|
|
}
|
|||
|
|
throw new CheckFailure('Loading event materialization did not converge; no source/event replay')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function recoverOriginalA(spec, original, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) {
|
|||
|
|
const path = `${PDA}/tasks/${spec.source.pdaTaskId}`
|
|||
|
|
const task = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
need(task.taskStatus === 'COMPLETION_FAILED' && task.completionCommand?.status === 'FAILED'
|
|||
|
|
&& id(task.completionCommand.id) === original.commandId && task.completionCommand.operationId === original.body.operationId
|
|||
|
|
&& Number(task.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Original failed command no longer matches; do not issue another command')
|
|||
|
|
verifyPhysicalFacts(spec.expected, task)
|
|||
|
|
const before = await sourceSnapshot(spec, request)
|
|||
|
|
for (const item of before.sources) {
|
|||
|
|
const actual = spec.expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0)
|
|||
|
|
need(actual ? item.view.summary.processStatus === 'LOADED' : item.view.summary.processStatus !== 'LOADED', 'Original completed/whole-skipped owner baseline mismatch')
|
|||
|
|
}
|
|||
|
|
await checkpoint({ stage: 'A_BEFORE_ORIGINAL_RECOVERY', task, original, before })
|
|||
|
|
try { dataOf(await request('POST', `${path}/complete`, original.body, 'pda')) }
|
|||
|
|
catch (error) {
|
|||
|
|
const readback = await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')
|
|||
|
|
await checkpoint({ stage: 'A_RECOVERY_REJECTED_OR_UNKNOWN_READBACK', readback })
|
|||
|
|
throw error // no blind repetition of a write with an uncertain result
|
|||
|
|
}
|
|||
|
|
let completed
|
|||
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|||
|
|
completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break
|
|||
|
|
need(completed.taskStatus === 'COMPLETING', `Original recovery failed: ${completed.completionCommand?.failureStage}/${completed.completionCommand?.failureCode}`)
|
|||
|
|
await sleep(1000)
|
|||
|
|
}
|
|||
|
|
need(completed.taskStatus === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId, 'Same original command must finish')
|
|||
|
|
verifyPhysicalFacts(spec.expected, completed)
|
|||
|
|
const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected)
|
|||
|
|
const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`))
|
|||
|
|
const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`))
|
|||
|
|
const planState = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`))
|
|||
|
|
const facts = verifyActual(spec.expected, detail, customs)
|
|||
|
|
need(planState.detailResource === 'LOADING_LIST' && planState.planStatus === 'COMPLETED', 'Recovered plan must expose completed loading-list lifecycle')
|
|||
|
|
const downstream = await waitDownstream(spec, request, sleep)
|
|||
|
|
await checkpoint({ stage: 'A_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, planState, downstream })
|
|||
|
|
const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda'))
|
|||
|
|
need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'Recovered completion audit must be unique')
|
|||
|
|
return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId,
|
|||
|
|
oldOwnersAndDomesticInventoryUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function verifyRecoveredA(spec, original, before, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) {
|
|||
|
|
const path = `${PDA}/tasks/${spec.source.pdaTaskId}`
|
|||
|
|
const completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED'
|
|||
|
|
&& completed.completionCommand?.status === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId
|
|||
|
|
&& completed.completionCommand.operationId === original.body.operationId
|
|||
|
|
&& Number(completed.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Same recovered original command must remain completed')
|
|||
|
|
verifyPhysicalFacts(spec.expected, completed)
|
|||
|
|
const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected)
|
|||
|
|
const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`))
|
|||
|
|
const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`))
|
|||
|
|
const plan = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`))
|
|||
|
|
const facts = verifyActual(spec.expected, detail, customs)
|
|||
|
|
need(plan.detailResource === 'LOADING_LIST' && plan.planStatus === 'COMPLETED', 'Completed original plan must remain completed')
|
|||
|
|
const downstream = await waitDownstream(spec, request, sleep)
|
|||
|
|
const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda'))
|
|||
|
|
need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'One original completion audit')
|
|||
|
|
await checkpoint({ stage: 'A_READONLY_VERIFIED_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, plan, downstream, operations })
|
|||
|
|
return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId, readOnlyVerification: true,
|
|||
|
|
oldOwnersAndDomesticAllocationSnapshotsUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validateZeroWorkTask(source, task, ownUserId) {
|
|||
|
|
need(id(task.id) === source.pdaTaskId && id(task.loadingPlanId) === source.loadingPlanId
|
|||
|
|
&& id(task.containerBookingId) === source.containerBookingId && task.currentContainerNo === source.containerNo
|
|||
|
|
&& task.taskStatus === 'LOADING' && Number(task.version) === Number(source.beforeTaskVersion), 'Original task identity/version changed')
|
|||
|
|
need(task.workflowStatus === 'UNASSIGNED' && task.assignment == null
|
|||
|
|
|| task.workflowStatus === 'IN_PROGRESS' && id(task.assignment?.assigneeId) === ownUserId,
|
|||
|
|
'Task must be unassigned or assigned to this own PDA session')
|
|||
|
|
need(rows(task.items).length > 0 && rows(task.items).every(item => item.itemStatus === 'PENDING'
|
|||
|
|
&& Number(item.pickedPackageCount) === 0), 'Prepared task already has physical work')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validatePreparedResume(report) {
|
|||
|
|
need(report.runId === 'PARTIAL-ACTUAL-20261004-recovery-r3' && report.success === false && rows(report.cases).length === 0
|
|||
|
|
&& report.recovery?.originalOwner?.inboundId === '2106706513799155714' && report.recovery?.originalSha256,
|
|||
|
|
'Only this original-source two-cabinet preparation may continue')
|
|||
|
|
need(!rows(report.requests).some(item => item.method === 'POST' && /\/pda-loading\/tasks\/\d+\/(claim|batch|complete|evidence)$/.test(item.path)),
|
|||
|
|
'Preparation continuation cannot repeat any PDA work')
|
|||
|
|
const prepared = rows(report.preparation).filter(item => item.stage === 'PDA_UNASSIGNED')
|
|||
|
|
need(prepared.length === 2 && prepared[0].kind === 'A' && prepared[1].kind === 'B'
|
|||
|
|
&& prepared.every(item => item.source?.sourceMarker === `${report.runId}-${item.kind}` && item.spec?.kind === item.kind), 'Exactly the prepared A/B checkpoints required')
|
|||
|
|
return prepared
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function multipartBody(file) {
|
|||
|
|
if (file instanceof FormData) return file
|
|||
|
|
need(file?.content !== undefined, 'Multipart descriptor requires content bytes')
|
|||
|
|
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
|
|||
|
|
const form = new FormData()
|
|||
|
|
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
|
|||
|
|
return form
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function requestInit(method, body, token, extraHeaders = {}) {
|
|||
|
|
const headers = new Headers({ Accept: 'application/json', ...extraHeaders })
|
|||
|
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|||
|
|
if (body instanceof FormData) headers.delete('Content-Type') // fetch supplies the actual boundary
|
|||
|
|
else if (body !== undefined) headers.set('Content-Type', 'application/json')
|
|||
|
|
return { method, redirect: 'error', signal: AbortSignal.timeout(30_000), headers,
|
|||
|
|
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function multipartAdapter(request, roleFor) {
|
|||
|
|
return (method, path, file, token, headers = {}) => request(method, path, multipartBody(file), roleFor(token), headers)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validateResumeProof(report, readback) {
|
|||
|
|
const owner = { tenantId: '2106706477225971713', orderId: '2106706495895306242', inboundId: '2106706513799155714',
|
|||
|
|
orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', marker: 'PARTIAL-ACTUAL-20261004-r1-A-REFUND' }
|
|||
|
|
need(report.runId === 'PARTIAL-ACTUAL-20261004-r1' && report.success === false && rows(report.cases).length === 0,
|
|||
|
|
'Only the explicitly authorized failed original run may resume')
|
|||
|
|
const rejected = rows(report.requests).filter(item => item.path === `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`)
|
|||
|
|
need(rejected.length === 1 && rejected[0].http === 200 && rejected[0].code === 'S0500' && rejected[0].requestId, 'Original one rejected upload required')
|
|||
|
|
const summary = readback.inbound?.summary, measurement = readback.measurement
|
|||
|
|
need(readback.verified === true && readback.sessionClosed === true && id(summary?.id) === owner.inboundId
|
|||
|
|
&& id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId && summary?.orderNo === owner.orderNo
|
|||
|
|
&& summary?.inboundCode === owner.inboundCode && summary?.processStatus === 'WAIT_MEASURE'
|
|||
|
|
&& measurement?.measurementStatus === 'DRAFT' && rows(measurement.weightResults).length === 0, 'Frozen readback must match original WAIT_MEASURE/DRAFT without weights')
|
|||
|
|
return owner
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validateResumeLive(owner, frozen, inbound, measurement) {
|
|||
|
|
const summary = inbound?.summary
|
|||
|
|
need(id(summary?.id) === owner.inboundId && id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId
|
|||
|
|
&& summary?.orderNo === owner.orderNo && summary?.inboundCode === owner.inboundCode
|
|||
|
|
&& summary?.processStatus === 'WAIT_MEASURE' && measurement?.measurementStatus === frozen.measurement.measurementStatus
|
|||
|
|
&& ['DRAFT', 'RECEIVED'].includes(measurement?.measurementStatus)
|
|||
|
|
&& rows(measurement.weightResults).length === 0 && id(measurement.measurementId) === id(frozen.measurement.measurementId)
|
|||
|
|
&& measurement.version === frozen.measurement.version && summary.version === frozen.inbound.summary.version,
|
|||
|
|
'Original sample changed; stop before any recovery write')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function resumeMeasurementDraft(context, owner, initial) {
|
|||
|
|
const root = `${LEG}/inbounds/${owner.inboundId}`, query = `tenantId=${owner.tenantId}`
|
|||
|
|
const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Original measurement rejected ${response.path}`); return response.body.data }
|
|||
|
|
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|||
|
|
const uploaded = ok(await context.requestMultipart('POST', `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`,
|
|||
|
|
{ content: bytes, type: 'image/png', name: `recovery-weight-${owner.inboundId}.png` }))
|
|||
|
|
need(uploaded.fileId, 'Original recovery upload did not return a file')
|
|||
|
|
const declarations = rows(initial.latestDeclaration?.items)
|
|||
|
|
need(declarations.length === 2 && declarations.map(item => Number(item.packageCount)).sort().join(',') === '1,3', 'Original two-SKU declaration must remain 3+1')
|
|||
|
|
// Save the two declared SKU identities separately. The generic single-SKU fixture
|
|||
|
|
// had aggregated all four boxes into the first detail; no carton scanning is repeated.
|
|||
|
|
const draft = ok(await context.request('PUT', `${root}/measurement/draft?${query}`, {
|
|||
|
|
measurementId: initial.measurementId, version: initial.version, actualGoodsName: '原样本两SKU恢复测量',
|
|||
|
|
items: declarations.map(item => ({ inboundDetailId: item.inboundDetailId, itemNo: String(item.itemNo),
|
|||
|
|
boxSpecification: item.cargoName, packageCount: Number(item.packageCount),
|
|||
|
|
lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm),
|
|||
|
|
intelligentImageFileId: uploaded.fileId, coefficientMode: 'ACTUAL', remark: owner.marker })),
|
|||
|
|
}))
|
|||
|
|
const options = ok(await context.request('GET', `${root}/weight-pallet-options?${query}&page=1&size=20&keyword=YW-TP-002`))
|
|||
|
|
const pallet = rows(options.records).find(item => item.palletCode === 'YW-TP-002')
|
|||
|
|
need(pallet?.palletId, 'Original source warehouse needs the existing YW-TP-002 pallet')
|
|||
|
|
const weight = ok(await context.request('POST', `${root}/weight-results?${query}`, { fileId: id(uploaded.fileId),
|
|||
|
|
palletId: id(pallet.palletId), ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }))
|
|||
|
|
const confirmed = ok(await context.request('POST', `${root}/weight-results/${weight.id}/confirm?${query}`, {
|
|||
|
|
version: weight.version, adjustedWeightValue: 100, adjustedWeightUnit: 'KG', adjustmentReason: `${owner.marker} HTTP人工称重夹具` }))
|
|||
|
|
need(confirmed.recognitionStatus === 'CONFIRMED', 'Original source weight must be confirmed')
|
|||
|
|
const current = ok(await context.request('GET', `${root}/measurement?${query}`))
|
|||
|
|
need(id(current.measurementId) === id(draft.measurementId) && current.measurementStatus === 'DRAFT', 'Original draft identity retained')
|
|||
|
|
const submitted = ok(await context.request('POST', `${root}/measurement/submit?${query}`, { measurementId: current.measurementId, version: current.version }))
|
|||
|
|
need(submitted.processStatus === 'MEASURE_PENDING', 'Original draft must enter actual measurement review')
|
|||
|
|
const pending = ok(await context.request('GET', `/api/platform/inbounds/${owner.inboundId}?${query}`))
|
|||
|
|
ok(await context.request('POST', `/api/platform/v1/inbounds/${owner.inboundId}/measurements/approve?${query}`,
|
|||
|
|
{ measurementId: current.measurementId, expectedVersion: pending.summary.version, reason: null }, context.token,
|
|||
|
|
{ 'Idempotency-Key': `${owner.marker}-recovery-approve` }))
|
|||
|
|
let final
|
|||
|
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|||
|
|
final = ok(await context.request('GET', `${root}/measurement?${query}`))
|
|||
|
|
if (final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED') break
|
|||
|
|
await new Promise(done => setTimeout(done, 1000))
|
|||
|
|
}
|
|||
|
|
need(final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED', 'Original recovery did not reach WAIT_SPLIT')
|
|||
|
|
need(rows(final.items).length === 2 && rows(final.items).every(item => declarations.some(declaration =>
|
|||
|
|
id(declaration.inboundDetailId) === id(item.inboundDetailId) && Number(declaration.packageCount) === Number(item.packageCount))), 'Original SKU measurements retain declaration identities and 3+1 counts')
|
|||
|
|
context.createdResources.inboundMeasurement = { marker: owner.marker, tenantId: owner.tenantId, inboundId: owner.inboundId,
|
|||
|
|
orderId: owner.orderId, inboundCode: owner.inboundCode, measurementId: id(final.measurementId),
|
|||
|
|
finalStatus: final.measurementStatus, processStatus: final.processStatus }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function redact(value) {
|
|||
|
|
if (Array.isArray(value)) return value.map(redact)
|
|||
|
|
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) =>
|
|||
|
|
[key, /password|token|secret|authorization/i.test(key) ? '[REDACTED]' : redact(child)]))
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// This is a bounded producer, not a run of the full acceptance module list.
|
|||
|
|
// The three reused modules only create qualified customer + submitted/approved/measured order.
|
|||
|
|
export async function prepareFreshCases(runId, context, savePrepared, resume = null) {
|
|||
|
|
const { run: qualification } = await import('./admin-test-env-modules/merchant-qualification-fixture.mjs')
|
|||
|
|
const { run: orders } = await import('./admin-test-env-modules/orders.mjs')
|
|||
|
|
const { run: measurement } = await import('./admin-test-env-modules/inbound-measurement.mjs')
|
|||
|
|
const { selectSessionPdaOperator } = await import('./admin-test-env-modules/domestic-pda-operator.mjs')
|
|||
|
|
const { publishPlatformLogisticsChannels, resolveOceanBookingAgent } = await import('./admin-test-env-modules/ocean-booking-fixture.mjs')
|
|||
|
|
const { ensureMatsonCarrierPartner } = await import('./admin-test-env-modules/carrier-partner-fixture.mjs')
|
|||
|
|
const { channelGroup } = await import('./admin-test-env-modules/smart-loading-channel-group.mjs')
|
|||
|
|
const { iso6346ContainerNumber } = await import('./admin-test-env-modules/iso6346-container-number.mjs')
|
|||
|
|
const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Fresh fixture rejected ${response.path}`); return response.body.data }
|
|||
|
|
const produced = []
|
|||
|
|
for (const [kind, definitions] of [['A', [['REFUND', 'TAX_REFUND', [3,1]], ['BUY', 'BUY_DECLARATION', [1]], ['SKIP', 'TAX_REFUND', [1]]]],
|
|||
|
|
['B', [['BUY', 'BUY_DECLARATION', [2]], ['SKIP', 'TAX_REFUND', [1]]]]]) {
|
|||
|
|
const owners = []
|
|||
|
|
for (const [role, taxMode, counts] of definitions) {
|
|||
|
|
context.marker = `${runId}-${kind}-${role}`
|
|||
|
|
context.context = { orderProfile: { taxMode, fulfillmentType: 'STANDARD' }, orderFulfillmentType: 'STANDARD',
|
|||
|
|
waitSplitVariant: { skuCount: counts.length, packageCounts: counts, packageCountPerSku: counts[0] } }
|
|||
|
|
context.createdResources = {}
|
|||
|
|
const originalRecovery = resume && kind === 'A' && role === 'REFUND'
|
|||
|
|
let originalMeasurement
|
|||
|
|
if (originalRecovery) {
|
|||
|
|
const original = resume.owner
|
|||
|
|
const inbound = ok(await context.request('GET', `/api/platform/inbounds/${original.inboundId}?tenantId=${original.tenantId}`))
|
|||
|
|
const measurementView = ok(await context.request('GET', `${LEG}/inbounds/${original.inboundId}/measurement?tenantId=${original.tenantId}`))
|
|||
|
|
validateResumeLive(original, resume.readback, inbound, measurementView)
|
|||
|
|
context.marker = original.marker
|
|||
|
|
context.createdResources.orders = { ...original, receivingWarehouseId: id(inbound.summary.warehouseId),
|
|||
|
|
fulfillmentType: 'STANDARD', taxMode: 'TAX_REFUND' }
|
|||
|
|
await savePrepared({ stage: 'ORIGINAL_RECOVERY_CHECKED', kind, role, ...original, measurementId: measurementView.measurementId,
|
|||
|
|
inboundVersion: inbound.summary.version, measurementVersion: measurementView.version })
|
|||
|
|
originalMeasurement = measurementView
|
|||
|
|
} else {
|
|||
|
|
await qualification(context, { publishEnvironment: false })
|
|||
|
|
await orders(context)
|
|||
|
|
}
|
|||
|
|
await savePrepared({ stage: 'APPROVED_ORDER', kind, role, tenantId: id(context.createdResources.orders?.tenantId),
|
|||
|
|
inboundId: id(context.createdResources.orders?.inboundId), orderId: id(context.createdResources.orders?.orderId) })
|
|||
|
|
if (originalRecovery) await resumeMeasurementDraft(context, resume.owner, originalMeasurement)
|
|||
|
|
else await measurement(context)
|
|||
|
|
const source = context.createdResources.inboundMeasurement
|
|||
|
|
need(source?.inboundId && source.processStatus === 'WAIT_SPLIT' && source.finalStatus === 'APPROVED', 'New legal measurement did not reach WAIT_SPLIT')
|
|||
|
|
owners.push({ role, taxMode, counts, tenantId: id(source.tenantId), inboundId: id(source.inboundId),
|
|||
|
|
orderId: id(source.orderId || context.createdResources.orders?.orderId), orderNo: context.createdResources.orders?.orderNo,
|
|||
|
|
inboundCode: source.inboundCode || context.createdResources.orders?.inboundCode })
|
|||
|
|
await savePrepared({ stage: 'WAIT_SPLIT', kind, owners: structuredClone(owners) })
|
|||
|
|
}
|
|||
|
|
context.marker = `${runId}-${kind}`
|
|||
|
|
const primary = owners[0]
|
|||
|
|
const inbound = ok(await context.request('GET', `/api/platform/inbounds/${primary.inboundId}?tenantId=${primary.tenantId}`))
|
|||
|
|
const warehouseId = id(inbound.summary.warehouseId), channelCode = inbound.summary.channelCode
|
|||
|
|
need(warehouseId && channelCode, 'Fresh source warehouse/channel missing')
|
|||
|
|
await publishPlatformLogisticsChannels(context, warehouseId, 'partial-loading-prepare')
|
|||
|
|
const agent = await resolveOceanBookingAgent(context, 'partial-loading-prepare', 'MATSON', { deploymentBlocker: true })
|
|||
|
|
const carrier = await ensureMatsonCarrierPartner(context, 'partial-loading-prepare', { deploymentBlocker: true })
|
|||
|
|
need(agent?.id && carrier?.id, 'Existing booking master data required')
|
|||
|
|
const container = ok(await context.request('POST', '/api/platform/v1/containers', {
|
|||
|
|
channelGroupCode: channelGroup(channelCode), warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 }))
|
|||
|
|
const bookingId = id(container.id)
|
|||
|
|
let smartPlan = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/by-booking/${bookingId}`))
|
|||
|
|
const planId = id(smartPlan.id)
|
|||
|
|
await savePrepared({ stage: 'EDITING', kind, owners, containerBookingId: bookingId, loadingPlanId: planId })
|
|||
|
|
const candidateResponse = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/candidates?bookingId=${bookingId}&warehouseId=${warehouseId}`))
|
|||
|
|
const candidates = rows(candidateResponse)
|
|||
|
|
for (const owner of owners) {
|
|||
|
|
const own = candidates.filter(item => id(item.inboundId) === owner.inboundId && id(item.accountId) === owner.tenantId)
|
|||
|
|
.sort((a,b) => id(a.inboundDetailId).localeCompare(id(b.inboundDetailId)))
|
|||
|
|
need(own.length === owner.counts.length, 'Fresh owner candidate SKU set mismatch')
|
|||
|
|
for (const candidate of own) {
|
|||
|
|
const packages = Number(candidate.availablePackageCount)
|
|||
|
|
need(owner.counts.includes(packages), 'Candidate count differs from fresh declared boxes')
|
|||
|
|
smartPlan = ok(await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${planId}/assignments`, {
|
|||
|
|
inboundId: owner.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: packages,
|
|||
|
|
expectedVersion: Number(smartPlan.version), operationId: `${context.marker}-assign-${candidate.inboundDetailId}` }))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const booking = ok(await context.request('POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: {
|
|||
|
|
channelCode: channelGroup(channelCode), warehouseId, billOfLadingNo: `BL-${context.marker}`,
|
|||
|
|
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
|||
|
|
bookingAgentId: id(agent.id), bookingAgentName: agent.name, vesselName: `测试船-${context.marker}`, voyageNo: 'PA-TEST',
|
|||
|
|
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
|||
|
|
estimatedSailingAt: '2026-10-05T08:00:00', estimatedArrivalAt: '2026-10-25T08:00:00', remark: context.marker,
|
|||
|
|
parties: ['SHIPPER','CONSIGNEE','NOTIFY_PARTY'].map(partyType => ({ partyType, partyName: `${context.marker}-${partyType}` })),
|
|||
|
|
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }],
|
|||
|
|
} }, context.token, { 'Idempotency-Key': `${context.marker}-booking` }))
|
|||
|
|
const oceanId = id(booking.oceanBooking.id), form = new FormData()
|
|||
|
|
form.append('expectedVersion', String(booking.oceanBooking.version)); form.append('documentType', 'BOOKING_CONFIRMATION')
|
|||
|
|
form.append('file', new Blob([`${context.marker} controlled TEST booking confirmation`], { type: 'text/plain' }), `${context.marker}-booking.txt`)
|
|||
|
|
const document = ok(await context.requestMultipart('POST', `/api/platform/v1/ocean-bookings/${oceanId}/documents/upload`, form,
|
|||
|
|
context.token, { 'Idempotency-Key': `${context.marker}-booking-file` }))
|
|||
|
|
const released = ok(await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanId}/release`,
|
|||
|
|
{ expectedVersion: Number(document.bookingVersion), reason: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-release` }))
|
|||
|
|
need(released.oceanBooking.status === 'RELEASED', 'New booking release required')
|
|||
|
|
const currentContainer = ok(await context.request('GET', `/api/platform/v1/containers/domestic/${bookingId}`)).container
|
|||
|
|
const containerNo = iso6346ContainerNumber(context.marker)
|
|||
|
|
ok(await context.request('POST', `/api/platform/v1/containers/${bookingId}/ocean-booking-link`, {
|
|||
|
|
oceanBookingId: oceanId, requirementId: id(released.containerRequirements[0].id), containerNo, expectedVersion: currentContainer.version }))
|
|||
|
|
const preload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`))
|
|||
|
|
const operator = selectSessionPdaOperator(ok(await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${warehouseId}`)), context.pdaToken)
|
|||
|
|
const confirmation = ok(await context.request('POST', `${LEG}/loading-plans/${planId}/confirm`,
|
|||
|
|
{ expectedVersion: Number(preload.summary.version), assigneeId: id(operator.userId) }, context.token, { 'Idempotency-Key': `${context.marker}-confirm` }))
|
|||
|
|
const taskId = id(confirmation.readiness.pdaTaskId)
|
|||
|
|
const task = ok(await context.request('GET', `${PDA}/tasks/${taskId}?warehouseId=${warehouseId}`, undefined, context.pdaToken))
|
|||
|
|
const source = { schemaVersion: 1, sourceMarker: context.marker, preparedAt: new Date().toISOString(), stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY',
|
|||
|
|
pdaTaskId: taskId, loadingPlanId: planId, containerBookingId: bookingId, warehouseId, containerNo,
|
|||
|
|
cabinetNo: container.cabinetNo, oceanBookingId: oceanId, bookingNo: released.oceanBooking.bookingNo, planNo: smartPlan.planNo,
|
|||
|
|
beforeTaskVersion: task.version, taskItems: task.items.map(item => ({ id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId),
|
|||
|
|
tenantId: id(item.tenantId), inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), plannedPackageCount: item.plannedPackageCount })) }
|
|||
|
|
const actions = task.items.map(item => {
|
|||
|
|
const owner = owners.find(owner => owner.inboundId === id(item.inboundId))
|
|||
|
|
need(owner, 'Task includes only fresh produced owners')
|
|||
|
|
const partial = owner === primary && Number(item.plannedPackageCount) === (kind === 'A' ? 3 : 2)
|
|||
|
|
return { itemId: id(item.id), action: partial ? 'PARTIAL' : owner.role === 'BUY' ? 'PICK' : 'SKIP',
|
|||
|
|
...(partial ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) }
|
|||
|
|
})
|
|||
|
|
const spec = { kind, actions, sealNo: `SEAL-${context.marker}` }
|
|||
|
|
const freshPreload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`))
|
|||
|
|
expectedRows(spec, task, freshPreload)
|
|||
|
|
produced.push({ spec, source, owners })
|
|||
|
|
await savePrepared({ stage: 'PDA_UNASSIGNED', kind, source, owners, spec })
|
|||
|
|
}
|
|||
|
|
return produced
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function validateManifest(manifest, runId) {
|
|||
|
|
need(manifest?.schemaVersion === 1 && manifest.environment === 'TEST' && manifest.runId === runId, 'Fixture run/environment mismatch')
|
|||
|
|
need(rows(manifest.cases).length === 2 && manifest.cases[0].kind === 'A' && manifest.cases[1].kind === 'B', 'Exactly A then B required')
|
|||
|
|
for (const item of manifest.cases) {
|
|||
|
|
need(typeof item.sourceReport === 'string' && item.sourceReport.length > 0, 'New official fixture source report required')
|
|||
|
|
need(rows(item.actions).length >= 2 && new Set(item.actions.map(row => row.itemId)).size === item.actions.length, 'Every PDA item needs one unique action')
|
|||
|
|
for (const action of item.actions) need(positive(action.itemId) && ['PICK', 'PARTIAL', 'SKIP'].includes(action.action)
|
|||
|
|
&& (action.action !== 'PARTIAL' || Number.isSafeInteger(action.finalPickedPackageCount) && action.finalPickedPackageCount > 0), 'Invalid PDA action')
|
|||
|
|
need(typeof item.sealNo === 'string' && item.sealNo.trim().length > 0 && item.sealNo.length <= 64, 'Seal required')
|
|||
|
|
need(TYPES.every(type => typeof item.evidence?.[type] === 'string' && item.evidence[type]), 'Five supplied evidence paths required')
|
|||
|
|
}
|
|||
|
|
return manifest
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fixtureSource(report, runId, gateway, now = Date.now()) {
|
|||
|
|
const value = report?.createdResources?.manualPdaBatchFixture
|
|||
|
|
need(report.success === true && report.fatal == null && report.summary?.failed === 0
|
|||
|
|
&& report.gateway?.replace(/\/$/, '') === gateway, 'Official fixture report did not pass in this TEST Gateway')
|
|||
|
|
need(value?.schemaVersion === 1 && value.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY'
|
|||
|
|
&& typeof value.sourceMarker === 'string' && value.sourceMarker.startsWith(`${runId}-`)
|
|||
|
|
&& /^[A-Za-z0-9_-]{1,80}$/.test(value.sourceMarker), 'Fresh run-bound PDA preparation required')
|
|||
|
|
const age = now - Date.parse(value.preparedAt)
|
|||
|
|
need(Number.isFinite(age) && age >= 0 && age <= 24 * 3600_000, 'Fixture must be prepared within this run/day')
|
|||
|
|
for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId', 'warehouseId']) need(positive(value[key]), `Invalid source ${key}`)
|
|||
|
|
need(rows(value.taskItems).length > 0, 'Missing original PDA identities')
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Decimal arithmetic is exact; no binary floating point at HALF_UP boundaries.
|
|||
|
|
export function scale(value, actual, planned, precision) {
|
|||
|
|
if (value == null) return null
|
|||
|
|
const text = String(value)
|
|||
|
|
need(/^\d+(?:\.\d+)?$/.test(text) && Number.isSafeInteger(actual) && actual > 0
|
|||
|
|
&& Number.isSafeInteger(planned) && planned >= actual, 'Invalid nonnegative frozen decimal/ratio')
|
|||
|
|
const [whole, fraction = ''] = text.split('.')
|
|||
|
|
const numerator = BigInt(whole + fraction) * BigInt(actual) * 10n ** BigInt(precision)
|
|||
|
|
const denominator = BigInt(planned) * 10n ** BigInt(fraction.length)
|
|||
|
|
const rounded = (2n * numerator + denominator) / (2n * denominator)
|
|||
|
|
const padded = rounded.toString().padStart(precision + 1, '0')
|
|||
|
|
return precision ? `${padded.slice(0, -precision)}.${padded.slice(-precision)}` : padded
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function expectedRows(caseSpec, task, preload) {
|
|||
|
|
const byId = new Map(rows(task.items).map(item => [id(item.id), item]))
|
|||
|
|
need(byId.size === task.items.length && byId.size === caseSpec.actions.length
|
|||
|
|
&& caseSpec.actions.every(item => byId.has(item.itemId)), 'Actions must cover every live PDA item exactly once')
|
|||
|
|
const planById = new Map(rows(preload.items).map(item => [id(item.itemId), item]))
|
|||
|
|
need(planById.size === byId.size, 'Preload and PDA scope mismatch')
|
|||
|
|
const expected = caseSpec.actions.map(action => {
|
|||
|
|
const pick = byId.get(action.itemId), frozen = planById.get(id(pick.loadingPlanItemId))
|
|||
|
|
need(frozen && id(frozen.accountId) === id(pick.tenantId) && id(frozen.inboundId) === id(pick.inboundId)
|
|||
|
|
&& id(frozen.inboundDetailId) === id(pick.inboundDetailId), 'Frozen source/customer/detail identity mismatch')
|
|||
|
|
const planned = Number(pick.plannedPackageCount)
|
|||
|
|
need(pick.itemStatus === 'PENDING' && Number(pick.pickedPackageCount) === 0 && pick.unitType === 'PACKAGE'
|
|||
|
|
&& !pick.palletId && planned > 0 && Number(frozen.packageCount) === planned, 'Require zero-picked PACKAGE sources')
|
|||
|
|
const actual = action.action === 'SKIP' ? 0 : action.action === 'PICK' ? planned : action.finalPickedPackageCount
|
|||
|
|
need(action.action !== 'PARTIAL' || actual < planned, 'PARTIAL must be less than original plan')
|
|||
|
|
return { action: action.action, pickId: action.itemId, itemId: id(frozen.itemId), accountId: id(frozen.accountId),
|
|||
|
|
inboundId: id(frozen.inboundId), inboundDetailId: id(frozen.inboundDetailId), taxMode: frozen.customsTaxMode,
|
|||
|
|
planned, actual, frozen, orderId: id(frozen.orderId) }
|
|||
|
|
})
|
|||
|
|
const partial = expected.filter(item => item.action === 'PARTIAL'), full = expected.filter(item => item.action === 'PICK')
|
|||
|
|
const skipped = expected.filter(item => item.action === 'SKIP'), active = expected.filter(item => item.actual > 0)
|
|||
|
|
need(expected.every(item => ['TAX_REFUND', 'BUY_DECLARATION'].includes(item.taxMode)), 'Authoritative tax mode required')
|
|||
|
|
if (caseSpec.kind === 'A') {
|
|||
|
|
need(partial.length === 1 && partial[0].taxMode === 'TAX_REFUND' && partial[0].planned === 3 && partial[0].actual === 2
|
|||
|
|
&& full.length === 1 && full[0].taxMode === 'BUY_DECLARATION' && full[0].accountId !== partial[0].accountId
|
|||
|
|
&& skipped.some(item => item.inboundId === partial[0].inboundId)
|
|||
|
|
&& skipped.some(item => item.taxMode === 'TAX_REFUND' && !active.some(row => row.accountId === item.accountId)), 'A needs mixed customers, partial refund, same-source skip and entirely skipped refund customer')
|
|||
|
|
} else {
|
|||
|
|
need(active.length === 1 && partial.length === 1 && full.length === 0
|
|||
|
|
&& partial[0].taxMode === 'BUY_DECLARATION' && partial[0].planned === 2 && partial[0].actual === 1
|
|||
|
|
&& skipped.length > 0 && skipped.every(item => item.taxMode === 'TAX_REFUND'), 'B must have exactly one actual BUY PARTIAL row and only skipped refund rows')
|
|||
|
|
}
|
|||
|
|
return expected
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function verifyActual(expected, detail, customs) {
|
|||
|
|
const active = expected.filter(item => item.actual > 0)
|
|||
|
|
assert.deepEqual(rows(detail.items).map(item => id(item.itemId)).sort(), active.map(item => item.itemId).sort(), 'Actual loading row set')
|
|||
|
|
for (const item of active) {
|
|||
|
|
const actual = detail.items.find(row => id(row.itemId) === item.itemId), frozen = item.frozen
|
|||
|
|
assert.equal(Number(actual.packageCount), item.actual, 'Actual boxes')
|
|||
|
|
assert.equal(id(actual.accountId), item.accountId, 'Actual customer')
|
|||
|
|
assert.equal(id(actual.inboundId), item.inboundId, 'Actual source')
|
|||
|
|
const units = Number(frozen.unitsPerPackage)
|
|||
|
|
const quantity = item.actual === item.planned ? Number(frozen.productQuantity)
|
|||
|
|
: units > 0 ? units * item.actual : Number(scale(frozen.productQuantity, item.actual, item.planned, 0))
|
|||
|
|
assert.equal(Number(actual.productQuantity), quantity, 'Actual units')
|
|||
|
|
for (const [field, precision] of [['grossWeightKg', 3], ['netWeightKg', 3], ['dimensionalWeightKg', 3], ['volumeCbm', 4]])
|
|||
|
|
assert.equal(scale(actual[field], 1, 1, precision), scale(frozen[field], item.actual, item.planned, precision), `Actual ${field}`)
|
|||
|
|
}
|
|||
|
|
const boxes = active.reduce((sum, item) => sum + item.actual, 0)
|
|||
|
|
assert.equal(Number(detail.summary.totalPackageCount), boxes, 'Actual total boxes')
|
|||
|
|
assert.equal(Number(customs.actualBoxCount), boxes, 'Customs actual boxes')
|
|||
|
|
const inboundKeys = values => [...new Set(values.map(item => `${id(item.tenantId ?? item.accountId)}:${id(item.inboundId)}`))].sort()
|
|||
|
|
assert.deepEqual(inboundKeys(rows(customs.inbounds)), inboundKeys(active), 'Customs source/customer set')
|
|||
|
|
assert.equal(Number(customs.taxRefundCount), new Set(active.filter(item => item.taxMode === 'TAX_REFUND').map(item => item.inboundId)).size, 'Refund coverage excludes skipped rows')
|
|||
|
|
assert.equal(Number(detail.summary.totalQuantity), detail.items.reduce((sum, item) => sum + Number(item.productQuantity), 0), 'Quantity total')
|
|||
|
|
for (const [field, summary, precision] of [['grossWeightKg', 'totalGrossWeightKg', 3], ['netWeightKg', 'totalNetWeightKg', 3], ['volumeCbm', 'totalVolumeCbm', 4]]) {
|
|||
|
|
const sum = detail.items.reduce((value, item) => value + BigInt(scale(item[field] ?? 0, 1, 1, precision).replace('.', '')), 0n)
|
|||
|
|
assert.equal(BigInt(scale(detail.summary[summary] ?? 0, 1, 1, precision).replace('.', '')), sum, `Actual ${summary}`)
|
|||
|
|
}
|
|||
|
|
return { actualBoxes: boxes, actualRows: active.length, taxRefundCount: Number(customs.taxRefundCount) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function runCase(spec, source, prepared, request, sleep = ms => new Promise(done => setTimeout(done, ms))) {
|
|||
|
|
const { expected, task, images } = prepared
|
|||
|
|
const path = `${PDA}/tasks/${source.pdaTaskId}`, key = suffix => `${source.sourceMarker}-${suffix}`.slice(0, 128)
|
|||
|
|
const data = response => { need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`); return response.body.data }
|
|||
|
|
const readTask = async () => data(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
const claimed = data(await request('POST', `${path}/claim`, { expectedAssignmentVersion: Number(task.assignment?.version ?? 0), operationId: key('claim') }, 'pda'))
|
|||
|
|
need(claimed.workflowStatus === 'IN_PROGRESS', 'Claim must enter IN_PROGRESS')
|
|||
|
|
let picked
|
|||
|
|
// Batch must contain every still-pending row, and forbids SKIP + physical picks
|
|||
|
|
// for one owner in the same command. Resolve that owner's zero-picked SKU through
|
|||
|
|
// the formal individual SKIP first; then one atomic batch handles all remaining rows.
|
|||
|
|
const individualSkips = expected.filter(item => item.action === 'SKIP'
|
|||
|
|
&& expected.some(other => other.inboundId === item.inboundId && other.actual > 0))
|
|||
|
|
for (const item of individualSkips) {
|
|||
|
|
const current = await readTask(), row = current.items.find(row => id(row.id) === item.pickId)
|
|||
|
|
data(await request('POST', `${path}/items/${item.pickId}/skip`, { warehouseId: source.warehouseId,
|
|||
|
|
version: Number(row.version), operationId: key(`skip-${item.pickId}`), reason: source.sourceMarker }, 'pda'))
|
|||
|
|
}
|
|||
|
|
const lines = expected.filter(item => !individualSkips.some(skip => skip.pickId === item.pickId))
|
|||
|
|
{
|
|||
|
|
const current = await readTask()
|
|||
|
|
const inventory = data(await request('GET', `${path}/inventory-snapshot?warehouseId=${source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
const batch = { warehouseId: source.warehouseId, loadingPlanId: source.loadingPlanId, containerBookingId: source.containerBookingId,
|
|||
|
|
expectedVersion: Number(current.version), inventorySnapshotToken: inventory.snapshotToken,
|
|||
|
|
items: lines.map(item => ({ itemId: item.pickId, action: item.action,
|
|||
|
|
version: Number(current.items.find(row => id(row.id) === item.pickId)?.version),
|
|||
|
|
...(item.action === 'PARTIAL' ? { finalPickedPackageCount: item.actual } : item.action === 'PICK' ? { pickedPackageCount: item.actual } : { reason: source.sourceMarker }) })) }
|
|||
|
|
need(inventory.snapshotToken && batch.items.every(item => Number.isSafeInteger(item.version)), 'Fresh inventory and item versions required')
|
|||
|
|
picked = data(await request('POST', `${path}/batch`, batch, 'pda', { 'Idempotency-Key': key('resolved-batch') }))
|
|||
|
|
}
|
|||
|
|
need(picked.workflowStatus === 'AWAITING_REVIEW' && expected.every(item => {
|
|||
|
|
const row = rows(picked.items).find(row => id(row.id) === item.pickId)
|
|||
|
|
return row && Number(row.pickedPackageCount) === item.actual && row.itemStatus === ({ PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' })[item.action]
|
|||
|
|
}), 'Resolved picks and all-PARTIAL B must be persisted before completion')
|
|||
|
|
for (const type of TYPES) {
|
|||
|
|
const image = images[type], form = new FormData()
|
|||
|
|
for (const [name, value] of Object.entries({ warehouseId: source.warehouseId, expectedVersion: 0, evidenceType: type,
|
|||
|
|
remark: source.sourceMarker, ...(type === 'SEAL' ? { sealNo: spec.sealNo } : {}) })) form.append(name, String(value))
|
|||
|
|
form.append('file', new Blob([image.bytes], { type: image.type }), `${source.sourceMarker}-${type}.${image.type === 'image/png' ? 'png' : 'jpg'}`)
|
|||
|
|
data(await request('POST', `${path}/evidence`, form, 'pda', { 'Idempotency-Key': key(`evidence-${type}`) }))
|
|||
|
|
}
|
|||
|
|
const evidence = data(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
need(TYPES.every(type => rows(evidence.evidences).some(item => item.evidenceType === type && item.status === 'ACTIVE'
|
|||
|
|
&& (type !== 'SEAL' || item.sealNo === spec.sealNo))), 'Five active evidence types and seal required')
|
|||
|
|
const ready = await readTask(), body = { expectedTaskVersion: Number(ready.version), operationId: key('complete'), remark: source.sourceMarker }
|
|||
|
|
data(await request('POST', `${path}/complete`, body, 'pda'))
|
|||
|
|
let completed
|
|||
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|||
|
|
completed = await readTask()
|
|||
|
|
if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break
|
|||
|
|
need(completed.taskStatus === 'COMPLETING', `Completion stopped: task=${completed.taskStatus}, command=${completed.completionCommand?.status ?? 'missing'}, stage=${completed.completionCommand?.failureStage ?? 'missing'}, code=${completed.completionCommand?.failureCode ?? 'missing'}`)
|
|||
|
|
await sleep(1000)
|
|||
|
|
}
|
|||
|
|
need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED', 'Completion polling exhausted')
|
|||
|
|
const snapshot = async () => ({ task: await readTask(),
|
|||
|
|
detail: data(await request('GET', `${LEG}/loading-lists/${source.loadingPlanId}?warehouseId=${source.warehouseId}`)),
|
|||
|
|
customs: data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`)),
|
|||
|
|
operations: data(await request('GET', `${path}/operations`, undefined, 'pda')) })
|
|||
|
|
const before = await snapshot(), facts = verifyActual(expected, before.detail, before.customs)
|
|||
|
|
need(rows(before.operations).filter(item => item.actionType === 'COMPLETE_LOADING').length === 1, 'One COMPLETE_LOADING audit required')
|
|||
|
|
data(await request('POST', `${path}/complete`, body, 'pda')) // original key, version and body
|
|||
|
|
const after = await snapshot()
|
|||
|
|
assert.equal(stableSnapshot(after), stableSnapshot(before), 'Exact completion replay has zero projection/version/history drift')
|
|||
|
|
if (spec.kind === 'B') {
|
|||
|
|
need(before.customs.taxRefundCount === 0 && before.customs.status === 'READY_TO_START', 'Actual all-buy readiness')
|
|||
|
|
const start = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/start`,
|
|||
|
|
{ expectedVersion: before.customs.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-start') }))
|
|||
|
|
need(start.status === 'DECLARING', 'All-buy start')
|
|||
|
|
const reject = await request('POST', `${CUSTOMS}/${source.loadingPlanId}/send-to-broker`,
|
|||
|
|
{ expectedVersion: start.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('refund-send-negative') })
|
|||
|
|
need(reject.http === 200 && String(reject.body?.code) === 'A0409', 'All-buy must reject refund send')
|
|||
|
|
const unchanged = data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`))
|
|||
|
|
assert.equal(stableSnapshot(unchanged), stableSnapshot(start), 'Rejected refund send leaves customs unchanged')
|
|||
|
|
const result = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/complete-all-buy`,
|
|||
|
|
{ expectedVersion: unchanged.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-complete') }))
|
|||
|
|
need(result.status === 'COMPLETED', 'All-buy completion')
|
|||
|
|
}
|
|||
|
|
return { kind: spec.kind, ...facts, source, actions: spec.actions, allActualRowsPartial: expected.filter(item => item.actual > 0).every(item => item.action === 'PARTIAL'),
|
|||
|
|
completed: true, completionReplayZeroDrift: true }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) {
|
|||
|
|
const options = executionOptions(args, execArgs, env)
|
|||
|
|
if (!options.execute) return plan()
|
|||
|
|
const directory = resolve(ROOT, 'reports', `partial-loading-actual-${options.runId}`)
|
|||
|
|
const bOnlyMode = typeof options.fixture === 'object' && options.fixture.bOnlyReport
|
|||
|
|
const verifyAMode = typeof options.fixture === 'object' && options.fixture.verifyAReport
|
|||
|
|
const completionMode = typeof options.fixture === 'object' && (options.fixture.completionReport || options.fixture.verifyAReport)
|
|||
|
|
const preparedMode = typeof options.fixture === 'object' && options.fixture.preparedReport
|
|||
|
|
const resumeMode = typeof options.fixture === 'object' && options.fixture.resumeReport
|
|||
|
|
const fresh = options.fixture === 'NEW' || resumeMode
|
|||
|
|
let resume
|
|||
|
|
if (resumeMode) {
|
|||
|
|
const originalBytes = await readFile(options.fixture.resumeReport)
|
|||
|
|
const readbackPath = resolve(dirname(options.fixture.resumeReport), 'blocked-readback.json')
|
|||
|
|
const readbackBytes = await readFile(readbackPath), readback = JSON.parse(readbackBytes)
|
|||
|
|
const original = JSON.parse(originalBytes)
|
|||
|
|
resume = { owner: validateResumeProof(original, readback), readback,
|
|||
|
|
originalReport: options.fixture.resumeReport, originalSha256: sha(originalBytes), readbackSha256: sha(readbackBytes) }
|
|||
|
|
const attemptedPath = resolve(ROOT, 'reports/partial-loading-actual-PARTIAL-ACTUAL-20261004-recovery-r1/report.json')
|
|||
|
|
let attemptedBytes
|
|||
|
|
try { attemptedBytes = await readFile(attemptedPath) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|||
|
|
if (attemptedBytes) {
|
|||
|
|
const attempted = JSON.parse(attemptedBytes)
|
|||
|
|
const mutations = rows(attempted.requests).filter(item => item.method !== 'GET' && !item.path.startsWith('/api/auth/') && !item.path.includes('carton-label-pdf'))
|
|||
|
|
need(attempted.success === false && attempted.recovery?.originalSha256 === resume.originalSha256
|
|||
|
|
&& mutations.length === 1 && mutations[0].path === `${LEG}/inbounds/${resume.owner.inboundId}/measurement/carton-scans?tenantId=${resume.owner.tenantId}`
|
|||
|
|
&& mutations[0].http === 200 && mutations[0].code === '200' && mutations[0].response.data.created === false
|
|||
|
|
&& mutations[0].response.data.measurementVersion === readback.measurement.version + 1,
|
|||
|
|
'Prior recovery must prove its sole accepted original re-scan; no blind retry')
|
|||
|
|
resume.readback = structuredClone(readback)
|
|||
|
|
resume.readback.measurement.version = mutations[0].response.data.measurementVersion
|
|||
|
|
resume.readback.measurement.measurementStatus = 'RECEIVED' // the proven scan changed this draft into RECEIVED
|
|||
|
|
resume.priorRecovery = { report: attemptedPath, sha256: sha(attemptedBytes),
|
|||
|
|
knownOriginalRescanRequestId: mutations[0].requestId, expectedMeasurementVersion: resume.readback.measurement.version,
|
|||
|
|
expectedMeasurementStatus: 'RECEIVED' }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const manifestBytes = bOnlyMode ? await readFile(options.fixture.bOnlyReport) : completionMode ? await readFile(options.fixture.completionReport || options.fixture.verifyAReport) : preparedMode ? await readFile(options.fixture.preparedReport) : fresh ? Buffer.from(resumeMode ? 'RESUME' : 'NEW') : await readFile(options.fixture)
|
|||
|
|
const manifest = fresh || preparedMode || completionMode || bOnlyMode ? null : validateManifest(JSON.parse(manifestBytes), options.runId)
|
|||
|
|
const sources = [], specs = []
|
|||
|
|
let original
|
|||
|
|
let originalBefore
|
|||
|
|
let aEventProof
|
|||
|
|
if (bOnlyMode) {
|
|||
|
|
const eventProofBytes = await readFile(resolve(ROOT, 'reports/partial-A-completion-event-proof-20261004-r1.json'))
|
|||
|
|
const eventProof = JSON.parse(eventProofBytes)
|
|||
|
|
need(eventProof.readOnly === true && eventProof.verified === true && Object.values(eventProof.checks).every(value => value === true)
|
|||
|
|
&& rows(eventProof.rows).length === 2 && rows(eventProof.rows).every(row => row.status === 'PROCESSED' && row.eventVersion === 3),
|
|||
|
|
'Controller exact A Outbox/ACK/Inbox proof required before B')
|
|||
|
|
aEventProof = { report: 'reports/partial-A-completion-event-proof-20261004-r1.json', sha256: sha(eventProofBytes), checks: eventProof.checks, events: eventProof.rows }
|
|||
|
|
let evidence = JSON.parse(manifestBytes)
|
|||
|
|
need(evidence.success === true && evidence.cases?.length === 1 && evidence.cases[0].kind === 'A'
|
|||
|
|
&& evidence.cases[0].originalCommandId === '2106716718935052290' && evidence.cases[0].actualPackages === 3
|
|||
|
|
&& evidence.cases[0].oldOwnersAndDomesticAllocationSnapshotsUnchanged === true && evidence.cases[0].wholeSkippedOwnerNotLoaded === true,
|
|||
|
|
'B requires the verified original A recovery first')
|
|||
|
|
for (let index = 0; index < 2; index++) {
|
|||
|
|
const parent = await readFile(evidence.completionRecovery.originalReport)
|
|||
|
|
need(sha(parent) === evidence.completionRecovery.originalSha256, 'A evidence chain SHA changed')
|
|||
|
|
evidence = JSON.parse(parent)
|
|||
|
|
}
|
|||
|
|
originalCompletionCommand(evidence)
|
|||
|
|
const preparationBytes = await readFile(evidence.preparedRecovery.report)
|
|||
|
|
need(sha(preparationBytes) === evidence.preparedRecovery.sha256, 'Original prepared B SHA changed')
|
|||
|
|
const preparation = JSON.parse(preparationBytes), checkpoint = validatePreparedResume(preparation)[1]
|
|||
|
|
const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}`
|
|||
|
|
const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}`
|
|||
|
|
const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1).response.data
|
|||
|
|
const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1).response.data
|
|||
|
|
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|||
|
|
specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload),
|
|||
|
|
images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }); sources.push(checkpoint.source)
|
|||
|
|
}
|
|||
|
|
if (completionMode) {
|
|||
|
|
let failed = JSON.parse(manifestBytes)
|
|||
|
|
if (verifyAMode) {
|
|||
|
|
originalBefore = rows(failed.checkpoints).find(row => row.stage === 'A_BEFORE_ORIGINAL_RECOVERY')?.before
|
|||
|
|
need(originalBefore && failed.completionRecovery?.originalReport, 'Original recovery before snapshot required')
|
|||
|
|
const originalBytes = await readFile(failed.completionRecovery.originalReport)
|
|||
|
|
need(sha(originalBytes) === failed.completionRecovery.originalSha256, 'Original failed report SHA changed')
|
|||
|
|
failed = JSON.parse(originalBytes)
|
|||
|
|
}
|
|||
|
|
original = originalCompletionCommand(failed)
|
|||
|
|
const preparationBytes = await readFile(failed.preparedRecovery.report)
|
|||
|
|
need(sha(preparationBytes) === failed.preparedRecovery.sha256, 'Original preparation report SHA changed')
|
|||
|
|
const preparation = JSON.parse(preparationBytes)
|
|||
|
|
for (const checkpoint of validatePreparedResume(preparation)) {
|
|||
|
|
const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}`
|
|||
|
|
const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}`
|
|||
|
|
const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1)?.response?.data
|
|||
|
|
const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1)?.response?.data
|
|||
|
|
specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload) }); sources.push(checkpoint.source)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (preparedMode) {
|
|||
|
|
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|||
|
|
for (const checkpoint of validatePreparedResume(JSON.parse(manifestBytes))) {
|
|||
|
|
specs.push({ ...checkpoint, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) })
|
|||
|
|
sources.push(checkpoint.source)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for (const spec of manifest?.cases || []) {
|
|||
|
|
const sourceBytes = await readFile(resolve(dirname(options.fixture), spec.sourceReport))
|
|||
|
|
const source = fixtureSource(JSON.parse(sourceBytes), options.runId, options.gateway)
|
|||
|
|
const images = {}
|
|||
|
|
for (const type of TYPES) {
|
|||
|
|
const bytes = await readFile(resolve(dirname(options.fixture), spec.evidence[type]))
|
|||
|
|
const png = bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
|
|||
|
|
const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255
|
|||
|
|
need(bytes.length > 16 && bytes.length <= 20 * 1024 * 1024 && (png || jpeg), 'Evidence must be supplied bounded PNG/JPEG bytes')
|
|||
|
|
images[type] = { bytes, type: png ? 'image/png' : 'image/jpeg', sha256: sha(bytes) }
|
|||
|
|
}
|
|||
|
|
sources.push(source); specs.push({ spec, source, sourceSha256: sha(sourceBytes), images })
|
|||
|
|
}
|
|||
|
|
if (!fresh && !bOnlyMode) {
|
|||
|
|
for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId']) need(new Set(sources.map(value => value[key])).size === 2, 'Two independent new cabinet identities required')
|
|||
|
|
need(!sources[0].taskItems.some(a => sources[1].taskItems.some(b => id(a.inboundId) === id(b.inboundId))), 'Cases cannot share source inbound resources')
|
|||
|
|
}
|
|||
|
|
await mkdir(directory) // exclusive; never overwrite evidence or silently rerun business writes
|
|||
|
|
const report = { success: false, runId: options.runId, startedAt: new Date().toISOString(), manifestSha256: sha(manifestBytes), requests: [], cases: [], sessions: {},
|
|||
|
|
...(bOnlyMode ? { BContinuation: { verifiedAReport: options.fixture.bOnlyReport, verifiedASha256: sha(manifestBytes), readyCommit: options.readyCommit, aEventProof }, syntheticHttpEvidence: true } : {}),
|
|||
|
|
...(completionMode ? { completionRecovery: { originalReport: options.fixture.completionReport || options.fixture.verifyAReport, originalSha256: sha(manifestBytes), original, readyCommit: options.readyCommit, BDeferred: true, readOnly: Boolean(verifyAMode) } } : {}),
|
|||
|
|
...(preparedMode ? { preparedRecovery: { report: options.fixture.preparedReport, sha256: sha(manifestBytes) }, syntheticHttpEvidence: true } : {}),
|
|||
|
|
...(resume ? { recovery: { originalReport: resume.originalReport, originalSha256: resume.originalSha256,
|
|||
|
|
readbackSha256: resume.readbackSha256, originalOwner: resume.owner, originalCreationSkipped: true, priorRecovery: resume.priorRecovery } } : {}),
|
|||
|
|
scope: completionMode ? ['direct Outbox/ACK audit', 'raw Warehouse SQL audit', 'browser/device acceptance', 'B deferred in this dispatch'] : plan().excluded,
|
|||
|
|
fixtures: specs.map(({ spec, source, sourceSha256, images }) => ({ kind: spec.kind, source, sourceSha256,
|
|||
|
|
...(images ? { imageHashes: Object.fromEntries(TYPES.map(type => [type, images[type].sha256])) } : {}) })) }
|
|||
|
|
const sessions = {}, save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(redact(report), null, 2))
|
|||
|
|
const nativeFetch = globalThis.fetch
|
|||
|
|
let requestQueue = Promise.resolve(), transportUnknown = false
|
|||
|
|
const serialFetch = (url, init) => {
|
|||
|
|
const pending = requestQueue.then(() => { need(!transportUnknown || init.method === 'GET' || new URL(url).pathname === '/api/auth/logout', 'Earlier unknown transport result; stop new commands and preserve samples'); return nativeFetch(url, init) })
|
|||
|
|
requestQueue = pending.then(() => {}, () => { transportUnknown = true })
|
|||
|
|
return pending
|
|||
|
|
}
|
|||
|
|
const recordSession = (payload, role) => {
|
|||
|
|
if (payload?.data?.accessToken) {
|
|||
|
|
const already = Object.entries(sessions).find(([, value]) => value.token === payload.data.accessToken)
|
|||
|
|
const name = already?.[0] || (role === 'admin' || role === 'pda' ? role : `customer-${Object.keys(sessions).length}`)
|
|||
|
|
sessions[name] = { token: payload.data.accessToken, refresh: payload.data.refreshToken }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function request(method, path, body, role = 'admin', headers = {}) {
|
|||
|
|
need(!verifyAMode || method === 'GET' || ['/api/auth/login','/api/auth/logout'].includes(path), 'A post-recovery verification is read-only')
|
|||
|
|
const auth = path.startsWith('/api/auth/')
|
|||
|
|
const entry = { method, path, role, intendedAt: new Date().toISOString(), state: 'PENDING',
|
|||
|
|
...(auth ? {} : body instanceof FormData ? { multipart: true } : body === undefined ? {} : { request: body }),
|
|||
|
|
idempotencyKey: headers['Idempotency-Key'] }
|
|||
|
|
if (body instanceof FormData) {
|
|||
|
|
entry.multipartParts = []
|
|||
|
|
for (const [name, value] of body.entries()) entry.multipartParts.push(typeof value === 'string' ? { name, value }
|
|||
|
|
: { name, fileName: value.name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) })
|
|||
|
|
}
|
|||
|
|
report.requests.push(entry); await save()
|
|||
|
|
try {
|
|||
|
|
const response = await serialFetch(options.gateway + path, requestInit(method, body, sessions[role]?.token, headers))
|
|||
|
|
const payload = await response.json()
|
|||
|
|
if (path === '/api/auth/login') recordSession(payload, role)
|
|||
|
|
Object.assign(entry, { state: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId,
|
|||
|
|
...(auth ? {} : { response: payload }) }); await save()
|
|||
|
|
return { method, path, http: response.status, body: payload, requestId: payload.requestId, traceId: payload.traceId }
|
|||
|
|
} catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Transport/response failed: ${method} ${path}; preserve original request, do not auto-retry`) }
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
for (const [role, username, password] of [['admin', env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD], ['pda', env.ADMIN_PDA_USERNAME, env.ADMIN_PDA_PASSWORD]]) {
|
|||
|
|
const response = await request('POST', '/api/auth/login', { loginId: username, password }, role)
|
|||
|
|
const data = response.body?.data
|
|||
|
|
if (data?.accessToken) sessions[role] = { token: data.accessToken, refresh: data.refreshToken }
|
|||
|
|
need(response.http === 200 && String(response.body?.code) === '200' && data?.accessToken && data?.refreshToken, 'Incomplete own login session')
|
|||
|
|
report.sessions[role] = { loggedIn: true, closed: false }
|
|||
|
|
}
|
|||
|
|
if (fresh) {
|
|||
|
|
// Reused producers have a few raw binary/multipart fetches. Instrument them too,
|
|||
|
|
// serialize through the same queue, persist intent first, and never expose credentials.
|
|||
|
|
globalThis.fetch = async (url, init = {}) => {
|
|||
|
|
const parsed = new URL(url); need(parsed.origin === options.gateway, 'Producer network must stay in TEST Gateway')
|
|||
|
|
const entry = { method: init.method || 'GET', path: parsed.pathname + parsed.search, producerRaw: true, intendedAt: new Date().toISOString(), state: 'PENDING' }
|
|||
|
|
const form = init.body instanceof FormData
|
|||
|
|
if (form) {
|
|||
|
|
entry.multipart = []
|
|||
|
|
for (const [name, value] of init.body.entries()) entry.multipart.push(typeof value === 'string'
|
|||
|
|
? { name, value } : { name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) })
|
|||
|
|
} else if (init.body) { try { entry.request = redact(JSON.parse(init.body)) } catch { entry.requestSha256 = sha(String(init.body)) } }
|
|||
|
|
report.requests.push(entry); await save()
|
|||
|
|
let response
|
|||
|
|
try { response = await serialFetch(url, { ...init, redirect: 'error' }) }
|
|||
|
|
catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Producer transport unknown: ${entry.method} ${entry.path}`) }
|
|||
|
|
const bytes = new Uint8Array(await response.clone().arrayBuffer())
|
|||
|
|
entry.http = response.status; entry.state = 'RECEIVED'
|
|||
|
|
if ((response.headers.get('content-type') || '').includes('json')) {
|
|||
|
|
const payload = JSON.parse(new TextDecoder().decode(bytes)); entry.code = payload.code
|
|||
|
|
entry.requestId = payload.requestId; entry.traceId = payload.traceId; entry.response = redact(payload)
|
|||
|
|
if (parsed.pathname === '/api/auth/login') recordSession(payload, 'producer')
|
|||
|
|
} else { entry.size = bytes.length; entry.sha256 = sha(bytes) }
|
|||
|
|
await save(); return response
|
|||
|
|
}
|
|||
|
|
const { AcceptanceRun, parseArguments } = await import('./admin-test-env-acceptance.mjs')
|
|||
|
|
const context = new AcceptanceRun({ ...parseArguments(['--modules','orders,inbound-measurement','--quiet','true']), gateway: options.gateway,
|
|||
|
|
username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: '', waitSplitCount: 1,
|
|||
|
|
routeMode: 'fixed', orderProfileMode: 'fixed' })
|
|||
|
|
context.token = sessions.admin.token; context.pdaToken = sessions.pda.token
|
|||
|
|
const originalStep = context.addStep.bind(context)
|
|||
|
|
context.addStep = (...args) => { const passed = originalStep(...args); if (!passed) throw new CheckFailure(`Fresh producer gate failed: ${args[0]} / ${args[1]}`); return passed }
|
|||
|
|
const roleFor = token => token ? Object.entries(sessions).find(([, value]) => value.token === token)?.[0] || 'admin' : 'producer'
|
|||
|
|
context.request = (method, path, body, token = context.token, headers = {}) => {
|
|||
|
|
need(method === 'GET' || !/^\/api\/platform\/v1\/(suppliers|business-partners|ocean-bookings\/channels)/.test(path), 'Shared booking master data writes require controller coordination')
|
|||
|
|
return request(method, path, body, roleFor(token), headers)
|
|||
|
|
}
|
|||
|
|
const adapter = multipartAdapter(request, roleFor)
|
|||
|
|
context.requestMultipart = (method, path, file, token = context.token, headers = {}) => adapter(method, path, file, token, headers)
|
|||
|
|
const freshCases = await prepareFreshCases(options.runId, context, async checkpoint => {
|
|||
|
|
report.preparation ||= []; report.preparation.push(checkpoint); await save()
|
|||
|
|
}, resume)
|
|||
|
|
globalThis.fetch = nativeFetch
|
|||
|
|
const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
|
|||
|
|
report.syntheticHttpEvidence = true // controlled HTTP fixture; never camera/device proof
|
|||
|
|
for (const value of freshCases) specs.push({ ...value, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) })
|
|||
|
|
report.fixtures = specs.map(({ spec, source, owners }) => ({ kind: spec.kind, source, owners }))
|
|||
|
|
await save()
|
|||
|
|
}
|
|||
|
|
const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Preflight API rejected ${response.path}`); return response.body.data }
|
|||
|
|
const ownPdaId = JSON.parse(Buffer.from(sessions.pda.token.split('.')[1], 'base64url').toString()).sub
|
|||
|
|
need(positive(ownPdaId), 'Own PDA subject required')
|
|||
|
|
// Both cabinets are checked before the first claim/batch/evidence/complete write.
|
|||
|
|
if (completionMode) {
|
|||
|
|
const checkpoint = async snapshot => {
|
|||
|
|
report.checkpoints ||= []; report.checkpoints.push(snapshot); await save()
|
|||
|
|
}
|
|||
|
|
report.cases.push(verifyAMode ? await verifyRecoveredA(specs[0], original, originalBefore, request, checkpoint)
|
|||
|
|
: await recoverOriginalA(specs[0], original, request, checkpoint))
|
|||
|
|
report.success = true // this dispatch is A-only; B is explicitly deferred and untouched
|
|||
|
|
} else {
|
|||
|
|
for (const item of specs) {
|
|||
|
|
const { source, spec } = item, path = `${PDA}/tasks/${source.pdaTaskId}`
|
|||
|
|
const task = ok(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
validateZeroWorkTask(source, task, ownPdaId)
|
|||
|
|
const originalIds = source.taskItems.map(row => id(row.id)).sort()
|
|||
|
|
assert.deepEqual(task.items.map(row => id(row.id)).sort(), originalIds, 'Source fixture item set')
|
|||
|
|
const preload = ok(await request('GET', `${LEG}/loading-plans/${source.loadingPlanId}?warehouseId=${source.warehouseId}`))
|
|||
|
|
need(['EDITING', 'LOADING'].includes(preload.summary.status), 'Zero-pick preload must be EDITING or own claimed LOADING')
|
|||
|
|
const operations = ok(await request('GET', `${path}/operations`, undefined, 'pda'))
|
|||
|
|
need(!rows(operations).some(row => ['BATCH', 'COMPLETE_LOADING'].includes(row.actionType))
|
|||
|
|
&& rows(operations).filter(row => row.actionType === 'CLAIM').every(row => row.operationId === `${source.sourceMarker}-claim`),
|
|||
|
|
'No earlier physical picking/completion or foreign claim allowed')
|
|||
|
|
const evidence = ok(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda'))
|
|||
|
|
need(!rows(evidence.evidences).some(row => row.status === 'ACTIVE'), 'New task must have empty active evidence baseline')
|
|||
|
|
item.task = task; item.expected = expectedRows(spec, task, preload)
|
|||
|
|
}
|
|||
|
|
for (const item of specs) {
|
|||
|
|
const result = await runCase(item.spec, item.source, item, request)
|
|||
|
|
if (bOnlyMode) {
|
|||
|
|
const downstream = await waitDownstream(item, request, ms => new Promise(done => setTimeout(done, ms)))
|
|||
|
|
result.downstream = downstream.facts; report.BAfter = { source: await sourceSnapshot(item, request), downstream }
|
|||
|
|
}
|
|||
|
|
report.cases.push(result); await save()
|
|||
|
|
}
|
|||
|
|
report.success = report.cases.length === (bOnlyMode ? 1 : 2)
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
report.failure = error instanceof CheckFailure || error instanceof assert.AssertionError ? error.message : 'Local response/fixture format failure'
|
|||
|
|
} finally {
|
|||
|
|
globalThis.fetch = nativeFetch
|
|||
|
|
// A transport-unknown business command stops all further business writes;
|
|||
|
|
// own session logout is still allowed and does not replay that command.
|
|||
|
|
transportUnknown = false
|
|||
|
|
for (const [role, session] of Object.entries(sessions).reverse()) {
|
|||
|
|
try {
|
|||
|
|
const response = await request('POST', '/api/auth/logout', { refreshToken: session.refresh }, role)
|
|||
|
|
const closed = response.http === 200 && String(response.body?.code) === '200'
|
|||
|
|
report.sessions[role] = { loggedIn: true, closed }; if (!closed) report.success = false
|
|||
|
|
} catch { report.sessions[role] = { loggedIn: true, closed: false }; report.success = false }
|
|||
|
|
}
|
|||
|
|
report.finishedAt = new Date().toISOString(); await save()
|
|||
|
|
}
|
|||
|
|
return { success: report.success, report: resolve(directory, 'report.json') }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|||
|
|
execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 })
|
|||
|
|
.catch(() => { console.error('Partial-loading acceptance did not start; check TEST env-file, new run-id and legal fixture manifest.'); process.exitCode = 1 })
|
|||
|
|
}
|