From 5ad3fec449998a485ed61050e2611923f5bec6a2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 00:44:28 +0800 Subject: [PATCH] test(firstleg): preserve partial and skipped loading lifecycle regression --- scripts/README.md | 2 + .../partial-loading-actual-acceptance-plan.md | 20 +- scripts/partial-loading-actual-test-env.mjs | 975 ++++++++++++++++++ .../partial-loading-actual-test-env.test.mjs | 349 +++++++ 4 files changed, 1345 insertions(+), 1 deletion(-) create mode 100644 scripts/partial-loading-actual-test-env.mjs create mode 100644 scripts/partial-loading-actual-test-env.test.mjs diff --git a/scripts/README.md b/scripts/README.md index b9ad0727f..5de33449a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜的 TEST 专项入口,默认无参数或 `--plan` 零网络。使用显式授权 TEST 环境文件,匹配 Firstleg/触发器就绪并开放独占样本窗口后,才可传 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,或用 `--fixture=` 提供本轮已核对来源。新准备只走客户资格、下单、测量、装柜正式接口;A 覆盖混合客户/税制、短拣和跳过,B 的全部实装行均为 PARTIAL、退税来源全部跳过。检查实际数量/指标、报关范围及同命令重放;合成图片只证明 HTTP,不是现场证据。报告保存至 `reports/partial-loading-actual-PARTIAL-ACTUAL-<批次>/`,先记录写入意图,拒绝覆盖;失败保留原样本和报告,未知结果不得自动重造或重试。仅在明确续跑授权和原快照核对后接续,退出自己的会话;不执行 SQL/部署、船节点或历史回填,不代替事件 ACK/下游库存、完整报关文件和页面/设备验收。直接测试 `partial-loading-actual-test-env.test.mjs`。 + `fba-remaining-nodes-acceptance-transfer.mjs` 是 F33 原已有库存转运的内部验收助手,导入不执行。`freezeTransfer` 使用显式 TEST 配置建立独立会话,只读原票和来源库存后退出;`prepareOriginalTransferPlan` / `prepareTransferStage` 只准备本票真实关联换标的逐阶段命令,不能预设关联号。`runPreparedTransferStage` 默认零网络,只有总控明确 TEST、原票、客户、阶段和稳定批次并注入已核身份的单请求流 transport 后才执行;照片以原字节摘要校验,客户确认使用本票 MERCHANT 身份,不调用被禁止的 PDA 独立复核批准。报告与写入 journal 在 `reports/fba-remaining-nodes-20261004/transfer/`,写前持久化意图;只有已明确成功的原键原载荷可显式重放一次,上传不可重放,失败或结果未知先回查,不能新建替代票、解冻其他业务或重新收货。调用者须逐阶段独立核对库存、其他 HOLD、追加流水和主体归属,HTTP 成功不自动标记业务通过;退出自身会话,不执行 Firstleg 写入、SQL、部署或删除。直接测试 `fba-remaining-nodes-acceptance-transfer.test.mjs`。 `fba-remaining-nodes-acceptance.mjs` 是 FBA 剩余节点内部证据助手,显式调用 `auditRemainingNodes`,导入不运行;读取指定原报告并按 JSON pointer/SHA-256 保留证据。`live=true` 仅使用明确 TEST 配置的独立 Auth 会话和串行 GET,结束退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告位于 `reports/fba-remaining-nodes-20261004/`,拒绝覆盖既有报告,失败保留且不自动重试。对应离线测试为 `fba-remaining-nodes-acceptance.test.mjs`,只读核验不等于原业务已完成。 diff --git a/scripts/partial-loading-actual-acceptance-plan.md b/scripts/partial-loading-actual-acceptance-plan.md index 69a14da18..9d208c72a 100644 --- a/scripts/partial-loading-actual-acceptance-plan.md +++ b/scripts/partial-loading-actual-acceptance-plan.md @@ -13,7 +13,7 @@ | 柜 | 来源与操作 | 验证目的 | |---|---|---| | A | 客户甲退税普通 PACKAGE 行计划 3 箱、每箱 7 件,实拣 2 箱并提交 PARTIAL;甲另一 SKU 零拣 SKIPPED;客户乙买单普通行全拣;客户丙退税整票零拣 SKIPPED | 混合客户/税制、部分数量缩放、同票跳过 SKU 与整客户跳过隔离 | -| B | 只有买单行实际装柜;同计划退税票全部 SKIPPED | 实际全买单柜不因计划历史退税票要求客户资料或退税发送 | +| B | 唯一实际买单 PACKAGE 行计划 2 箱、实拣 1 箱并提交 PARTIAL;同计划退税票全部 SKIPPED | 全部实际行均 PARTIAL 的完成预检,以及实际全买单柜不因计划历史退税票要求客户资料或退税发送 | 从预装详情冻结每行 itemId、客户/来源/明细三键、原箱数、unitsPerCarton、productQuantity、palletCount、重量和体积。件数先按每箱件数×实箱;无每箱件数时按原量比例 HALF_UP 至整数。重量 HALF_UP 至 3 位、体积至 4 位;NULL 保持 NULL。托数按原计划托数比例向上取整。托数为空/0 的标签回退另核对原入仓总箱数、合法确认称重/库位证据,不能用本次实拣总数替换原比例分母。样本中的未知值只来自正式接口允许的输入,不以 SQL 修改事实制造样本。 @@ -48,3 +48,21 @@ 跨客户第二来源缺客户/目的仓快照、非法 PARTIAL、未结算最终发布等服务内非法事实,使用 `LoadingCompletedPreflightDatabaseTest` 的真实 Mapper 场景核验;不得通过 TEST SQL 捏造非法业务数据。有效新样本的跨客户正常完成必须走上述真实 HTTP。H2 对 MySQL UPDATE JOIN 的语法适配与 pendingAutoDispatch 关联替换均需在报告中披露;真实 MySQL 触发器与最终完成另验。 证据建议落在 `reports/partial-loading-actual-<批次>/`:版本/健康与触发器就绪证明、请求序列、before/after 全量快照、逐断言结果、事件/接收端只读取证、失败原件、会话退出结果。执行结束退出自己的全部会话,不删除数据或恢复他人资源。HTTP/DOM证据与人工1920×1080、100%缩放及PDA设备验收分别报告,不据服务测试宣布页面或全流程完成。 + +## 最小可执行入口及交给总控的 README 登记文本 + +下列段落应由总控集成到 `scripts/README.md`;本任务不修改共享索引。 + +> `partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜 TEST 专项入口,默认无参数或 `--plan` 零网络。执行必须在测试后端工作区、使用脚本前的 `node --env-file=<用户指定目录的test-acceptance.env>`,并传入 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,仅在总控确认匹配 Firstleg 与受控触发器、开放新样本窗口后运行。NEW 模式复用既有资格/订单/测量合法模块,仅准备五个隔离客户/订单与两柜;A 为退税 3→2 PARTIAL、同来源 SKU SKIP、另一客户买单全拣及整客户退税 SKIP,B 为唯一实际买单 2→1 PARTIAL 且退税全部 SKIP。也支持 `--fixture=` 消费本轮通过的 `manual-pda-batch-fixture` 报告,所有动作覆盖每条 PDA 明细,限定全新未领取、零拣货且跨柜不共享来源的样本。入口执行领取、原子 batch、五类证据、唯一完成、实装数量/件数/重量/体积和报关范围、原完成命令重放零漂移,以及 B 全买单报关开始/完成和退税发送拒绝;NEW 模式照片为明确标记的合成 HTTP 夹具,不是现场照片或真机证据。报告 `reports/partial-loading-actual-PARTIAL-ACTUAL-<新批次>/report.json` 使用独占目录,先保存原请求/幂等意图,凭据脱敏、单请求流;失败保留原样本,未知传输结果阻断后续命令,不自动重造、覆盖、续跑或重放历史事件。退出自己的所有会话;不操作旧客户库存、SQL、部署或船节点。B 全买单报关完成后停在合法当前状态供总控复用,不能自行推进进港、提柜或卸柜。此入口不展开 F01–F40 全量,不代替事件 ACK、下游库存、完整退税资料/XLSX及人工页面/PDA证据。直接离线测试为 `partial-loading-actual-test-env.test.mjs`。 + +当前可执行最小范围与上文完整方案分开:两柜完成/报关范围有可执行断言;事件、下游、客户资料隔离和 XLSX 必须另取实际证据,不将方案中的未执行项写成通过。 + +### 原样本恢复与准备检查点续接(追加 README 文本) + +> 本入口支持本轮明确授权的 `--fixture=RESUME:<原失败report.json>`:核对冻结 `blocked-readback.json` 的原客户、订单、入仓码、测量批次、版本及无称重基线,将旧报告/回读 SHA 写入新的恢复目录;不再资格、创建客户或下单。测量直接从原草稿续接上传、独立 SKU 明细、称重确认、提交和审核,不运行“首次扫描新增”夹具断言。multipart 适配支持文件描述对象与现成 FormData,文件字节保留、Content-Type boundary 由 fetch 生成;直接测试使用本地真实 HTTP 接收原始字节。`--fixture=PREPARED:<本轮两柜准备report.json>` 只续接这次原来源形成的两柜零拣货检查点,不重新创建任何客户、订单或柜;任务可为未分配,或已分配给当前独立 PDA 会话的 IN_PROGRESS,仍须冻结版本、全部 PENDING/零实拣及无外部作业记录。任务已发生实拣或完成后不得用此模式重跑。同来源 SKU 的零拣跳过先走正式逐明细 SKIP,再以刷新后的版本和库存快照一次 batch 处理全部剩余待拣;不能把 SKIP 与同来源的实拣放在同一 batch,也不能提交只含部分待拣明细的 batch。原完成失败必须保留命令和失败状态,生产修复与原命令恢复由总控另行协调,不自动重新造柜、改业务事实或继续人工船节点。 + +### A 原完成命令恢复与纯读取证(追加 README 文本) + +> `partial-loading-actual-test-env.mjs` 的 `--fixture=COMPLETION:` 模式仅恢复原 A command `2106716718935052290`,先从旧报告校验唯一已受理请求和准备报告 SHA;必须同时给出 `--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae`,且已收到总控匹配 Firstleg 就绪及恢复授权。请求体严格固定为 `expectedTaskVersion=2`、`operationId=PARTIAL-ACTUAL-20261004-recovery-r3-A-complete`、`remark=PARTIAL-ACTUAL-20261004-recovery-r3-A`,不使用失败后新 task version 创建另一命令。先保存两旧 LOADED owner、全跳来源及源分配数量/绑定快照,再只 POST 一次正式 complete;遇未知结果仅回查并停止,不盲重试。完成后对比旧来源版本/历史/源分配快照、正实拣集合、清单/报关实际数、Lastleg 事件建单历史和初始在途余额/流水;全部跳过来源不得 LOADED 或获得尾程/库存。B 在此模式明确暂缓,不做资格、下单、拣货、海运节点或 SQL。 +> +> 若原命令已完成,但后续取证失败,使用新的报告目录和 `--fixture=VERIFY_A:<原COMPLETION执行report.json>` 纯读模式接续,仍带相同 ready commit;该模式只允许 GET 及自有登录/退出,不再 complete。库存列表是按维度聚合,入仓归属字段为空时必须沿 `/inventory/balances/{代表id}` 详情读取真实归属;`totalPackages` 表示在仓桶,在途数核对 `inTransitPackages`,不能把在途要求加入在仓总数。公开消费建单/流水事实只证明实装事件结果,不代替 Firstleg Outbox 发布状态、ACK/Inbox SQL 审计;精确 SQL 交总控串行读取。原失败和所有恢复/读取报告均保留,不覆盖。 diff --git a/scripts/partial-loading-actual-test-env.mjs b/scripts/partial-loading-actual-test-env.mjs new file mode 100644 index 000000000..5a4e81e51 --- /dev/null +++ b/scripts/partial-loading-actual-test-env.mjs @@ -0,0 +1,975 @@ +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-', environment: 'TEST', + cases: ['A', 'B'].map(kind => ({ kind, sourceReport: '', + actions: [{ itemId: '', action: 'PARTIAL', finalPickedPackageCount: kind === 'A' ? 2 : 1 }], + evidence: Object.fromEntries(TYPES.map(type => [type, ''])), sealNo: '' })) }, + execution: 'node --env-file= scripts/partial-loading-actual-test-env.mjs --execute --environment=TEST --run-id= --fixture=' } +} + +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 }) +} diff --git a/scripts/partial-loading-actual-test-env.test.mjs b/scripts/partial-loading-actual-test-env.test.mjs new file mode 100644 index 000000000..c8ddc93ca --- /dev/null +++ b/scripts/partial-loading-actual-test-env.test.mjs @@ -0,0 +1,349 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { plan, execute, executionOptions, validateManifest, fixtureSource, expectedRows, scale, verifyActual, runCase, redact, + multipartAdapter, requestInit, validateResumeProof, validateResumeLive, resumeMeasurementDraft, validateZeroWorkTask, validatePreparedResume, + originalCompletionCommand, verifyPhysicalFacts, verifyOldOwnersUnchanged, verifyDownstream } from './partial-loading-actual-test-env.mjs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { createServer } from 'node:http' + +const runId = 'PARTIAL-ACTUAL-offline' +const evidence = Object.fromEntries(['PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL'].map(type => [type, 'sample.png'])) +function fixture(kind = 'B') { + const definitions = kind === 'B' ? [[1, 11, 2, 'BUY_DECLARATION'], [2, 22, 1, 'TAX_REFUND']] + : [[1, 11, 3, 'TAX_REFUND'], [2, 11, 1, 'TAX_REFUND'], [3, 22, 1, 'BUY_DECLARATION'], [4, 33, 1, 'TAX_REFUND']] + const task = { id: '91', loadingPlanId: '90', containerBookingId: '92', version: 0, + items: definitions.map(([n, tenant, count]) => ({ id: String(n), loadingPlanItemId: String(n + 100), tenantId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), plannedPackageCount: count, + pickedPackageCount: 0, itemStatus: 'PENDING', unitType: 'PACKAGE', palletId: null, version: 0 })) } + const preload = { items: definitions.map(([n, tenant, count, tax]) => ({ itemId: String(n + 100), accountId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), customsTaxMode: tax, orderId: String(n + 300), + packageCount: count, productQuantity: count * 7, unitsPerPackage: 7, grossWeightKg: '10.001', netWeightKg: '8.002', dimensionalWeightKg: '6.004', volumeCbm: '0.1001' })) } + const spec = { kind, sourceReport: `${kind}.json`, evidence, sealNo: 'SEAL-TEST', actions: definitions.map(([n]) => ({ itemId: String(n), + action: n === 1 ? 'PARTIAL' : kind === 'A' && n === 3 ? 'PICK' : 'SKIP', ...(n === 1 ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) })) } + const expected = expectedRows(spec, task, preload) + const actualItems = expected.filter(row => row.actual > 0).map(row => ({ ...row.frozen, packageCount: row.actual, + productQuantity: row.actual * 7, ...Object.fromEntries(['grossWeightKg','netWeightKg','dimensionalWeightKg','volumeCbm'].map(field => + [field, scale(row.frozen[field], row.actual, row.planned, field === 'volumeCbm' ? 4 : 3)])) })) + const sum = (field, digits) => Number(actualItems.reduce((value, item) => value + BigInt(scale(item[field], 1, 1, digits).replace('.', '')), 0n)) / 10 ** digits + const detail = { items: actualItems, summary: { totalPackageCount: actualItems.reduce((sum, row) => sum + row.packageCount, 0), + totalQuantity: actualItems.reduce((sum, row) => sum + row.productQuantity, 0), totalGrossWeightKg: sum('grossWeightKg', 3), + totalNetWeightKg: sum('netWeightKg', 3), totalVolumeCbm: sum('volumeCbm', 4) } } + const customs = { actualBoxCount: detail.summary.totalPackageCount, inbounds: expected.filter(row => row.actual > 0), + taxRefundCount: kind === 'B' ? 0 : 1, version: 0, status: kind === 'B' ? 'READY_TO_START' : 'WAITING_CUSTOMER_DOCUMENTS' } + const source = { pdaTaskId: '91', loadingPlanId: '90', containerBookingId: '92', warehouseId: '1', sourceMarker: `${runId}-${kind}` } + return { task, preload, spec, expected, detail, customs, source } +} + +test('default plan and --plan make zero network calls', async () => { + const original = globalThis.fetch + globalThis.fetch = () => { throw Error('No network in plan') } + try { assert.deepEqual(await execute([], [], {}), plan()); assert.equal((await execute(['--plan'], [], {})).networkRequests, 0) } + finally { globalThis.fetch = original } +}) + +test('execution refuses absent explicit TEST/profile/cwd', () => { + assert.throws(() => executionOptions(['--execute'], [], {})) + assert.throws(() => executionOptions(['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=x.json'], [], {}, 'C:/other')) +}) + +test('NEW mode uses the configured /api base and does not accept a bare origin', () => { + const cwd = fileURLToPath(new URL('..', import.meta.url)) + const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid/api', + CHONGCHONG_ADMIN_USERNAME: 'admin', CHONGCHONG_ADMIN_PASSWORD: 'test-only', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'test-only' } + const args = ['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=NEW'] + const execArgs = [`--env-file=${resolve('/.codex/env/chongchong-global/test-acceptance.env')}`] + assert.equal(executionOptions(args, execArgs, env, cwd).fixture, 'NEW') + assert.equal(executionOptions(args, execArgs, env, cwd).gateway, 'https://test.invalid') + assert.throws(() => executionOptions(args, execArgs, { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid' }, cwd), /end in/) +}) + +test('nested generated customer passwords and login/refresh tokens never reach saved evidence', () => { + const value = redact({ requests: [{ response: { data: { temporaryPassword: 'SECRET', nested: { accessToken: 'TOKEN', refreshToken: 'REFRESH' }, tenantId: '11' } } }] }) + const json = JSON.stringify(value) + assert.ok(!json.includes('SECRET') && !json.includes('TOKEN') && !json.includes('REFRESH')) + assert.ok(json.includes('"tenantId":"11"')) +}) + +test('real HTTP multipart adapter preserves descriptor bytes, existing form fields and generated boundary', async () => { + const received = [], original = Uint8Array.from([137,80,78,71,13,10,26,10,0,255,10,13,0,200]) + const server = createServer(async (request, response) => { + const chunks = []; for await (const chunk of request) chunks.push(chunk) + received.push({ headers: request.headers, body: Buffer.concat(chunks), method: request.method }) + response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"code":"200"}') + }) + await new Promise(done => server.listen(0, '127.0.0.1', done)) + try { + const base = `http://127.0.0.1:${server.address().port}` + const adapter = multipartAdapter((method, path, body, role, headers) => { + assert.equal(role, 'admin'); return fetch(base + path, requestInit(method, body, 'test-token', headers)) + }, () => 'admin') + await adapter('POST', '/upload', { content: original, type: 'image/png', name: 'weight.png' }, 'token') + const form = new FormData(); form.append('expectedVersion', '4'); form.append('file', new Blob([original], { type: 'image/png' }), 'original.png') + await adapter('POST', '/upload', form, 'token', { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-key' }) + assert.equal(received.length, 2) + for (const request of received) { + assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/) + const boundary = request.headers['content-type'].split('boundary=')[1] + assert.ok(request.body.subarray(0, boundary.length + 2).equals(Buffer.from(`--${boundary}`))) + assert.ok(request.body.includes(Buffer.from(original)), 'The original byte array reaches HTTP without JSON conversion') + assert.ok(request.body.includes(Buffer.from('name="file"'))) + assert.ok(!request.body.includes(Buffer.from('name="content"'))) + assert.equal(request.method, 'POST') + } + assert.ok(received[1].body.includes(Buffer.from('name="expectedVersion"\r\n\r\n4'))) + assert.equal(received[1].headers['idempotency-key'], 'same-key') + } finally { server.closeAllConnections(); await new Promise(done => server.close(done)) } +}) + +test('explicit resume proves the original failed sample and refuses state/identity drift before writes', () => { + const summary = { id: '2106706513799155714', tenantId: '2106706477225971713', orderId: '2106706495895306242', + orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', processStatus: 'WAIT_MEASURE', version: 4 } + const measurement = { measurementId: '2106706548955811842', measurementStatus: 'DRAFT', version: 4, weightResults: [] } + const readback = { verified: true, sessionClosed: true, inbound: { summary }, measurement } + const report = { runId: 'PARTIAL-ACTUAL-20261004-r1', success: false, cases: [], requests: [{ path: '/api/platform/v1/files/measurements/2106706477225971713/2106706513799155714', http: 200, code: 'S0500', requestId: 'original-id' }] } + const owner = validateResumeProof(report, readback) + validateResumeLive(owner, readback, readback.inbound, measurement) + assert.throws(() => validateResumeProof({ ...report, runId: 'unrelated' }, readback), /explicitly authorized/) + assert.throws(() => validateResumeLive(owner, readback, { summary: { ...summary, version: 5 } }, measurement), /changed/) + assert.throws(() => validateResumeLive(owner, readback, readback.inbound, { ...measurement, weightResults: [{ id: '1' }] }), /changed/) + assert.throws(() => validateResumeProof(report, { ...readback, measurement: { ...measurement, measurementStatus: 'APPROVED' } }), /Frozen/) +}) + +test('original draft resumes by upload/measurement only, with independent 3+1 SKU rows', async () => { + const owner = { tenantId: '11', inboundId: '12', orderId: '13', marker: 'ORIGINAL' } + const initial = { measurementId: '14', version: 5, latestDeclaration: { items: [ + { inboundDetailId: '21', itemNo: 1, cargoName: 'SKU1', packageCount: 3, lengthCm: 30, widthCm: 20, heightCm: 20 }, + { inboundDetailId: '22', itemNo: 2, cargoName: 'SKU2', packageCount: 1, lengthCm: 40, widthCm: 30, heightCm: 30 }] } } + const calls = []; let approved = false + const context = { token: 'own-token', createdResources: {}, request: async (method, path, body) => { + calls.push({ method, path, body }); let data + if (path.includes('/files/measurements/')) data = { fileId: '41' } + else if (path.includes('/measurement/draft?')) data = { measurementId: '14' } + else if (path.includes('/weight-pallet-options?')) data = { records: [{ palletId: '51', palletCode: 'YW-TP-002' }] } + else if (path.includes('/weight-results/61/confirm?')) data = { recognitionStatus: 'CONFIRMED' } + else if (path.includes('/weight-results?')) data = { id: '61', version: 0 } + else if (path.includes('/measurement/submit?')) data = { processStatus: 'MEASURE_PENDING' } + else if (path.includes('/measurements/approve?')) { approved = true; data = {} } + else if (path.includes('/measurement?')) data = { measurementId: '14', version: 6, measurementStatus: approved ? 'APPROVED' : 'DRAFT', + processStatus: approved ? 'WAIT_SPLIT' : 'WAIT_MEASURE', items: initial.latestDeclaration.items } + else if (path.startsWith('/api/platform/inbounds/')) data = { summary: { version: 7 } } + else throw Error('Unexpected original-recovery endpoint') + return { path, http: 200, body: { code: '200', data } } + } } + context.requestMultipart = multipartAdapter(context.request, () => 'admin') + await resumeMeasurementDraft(context, owner, initial) + assert.equal(context.createdResources.inboundMeasurement.inboundId, '12') + assert.equal(context.createdResources.inboundMeasurement.processStatus, 'WAIT_SPLIT') + assert.ok(calls[0].body instanceof FormData) + assert.deepEqual(calls.find(call => call.path.includes('/measurement/draft?')).body.items.map(item => [item.inboundDetailId, item.packageCount]), [['21',3],['22',1]]) + assert.ok(!calls.some(call => /carton-scans|\/iam\/tenants|\/orders/.test(call.path)), 'No qualification, creation, order submission or re-scan') +}) + +test('zero-work checkpoint accepts own preassignment but rejects another operator or any physical pick', () => { + const { source, task } = fixture() + task.taskStatus = 'LOADING'; task.workflowStatus = 'IN_PROGRESS'; task.assignment = { assigneeId: '99' } + source.beforeTaskVersion = 0 + validateZeroWorkTask(source, task, '99') + assert.throws(() => validateZeroWorkTask(source, task, '98'), /own PDA/) + task.items[0].pickedPackageCount = 1 + assert.throws(() => validateZeroWorkTask(source, task, '99'), /physical work/) +}) + +test('prepared continuation binds the two original-source checkpoints and refuses to redo PDA writes', () => { + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r3', success: false, cases: [], requests: [], + recovery: { originalOwner: { inboundId: '2106706513799155714' }, originalSha256: 'frozen-original-hash' }, + preparation: ['A','B'].map(kind => ({ kind, stage: 'PDA_UNASSIGNED', source: { sourceMarker: `PARTIAL-ACTUAL-20261004-recovery-r3-${kind}` }, spec: { kind } })) } + assert.equal(validatePreparedResume(report).length, 2) + assert.throws(() => validatePreparedResume({ ...report, requests: [{ method: 'POST', path: '/api/platform/v1/first-leg/pda-loading/tasks/91/batch' }] }), /repeat any PDA/) + assert.throws(() => validatePreparedResume({ ...report, cases: [{ completed: true }] }), /Only this/) +}) + +test('A recovery takes only the original command id and immutable version/operation/remark', () => { + const body = { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' } + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r7', success: false, requests: [{ method: 'POST', + path: '/api/platform/v1/first-leg/pda-loading/tasks/2106713892758491138/complete', state: 'RECEIVED', http: 200, code: '200', request: body, + response: { data: { completionCommand: { id: '2106716718935052290', operationId: body.operationId, expectedTaskVersion: 2 } } } }] } + assert.deepEqual(originalCompletionCommand(report), { commandId: '2106716718935052290', body }) + const changed = structuredClone(report); changed.requests[0].request.expectedTaskVersion = 4 + assert.throws(() => originalCompletionCommand(changed), /immutable/) + const duplicated = structuredClone(report); duplicated.requests.push(duplicated.requests[0]) + assert.throws(() => originalCompletionCommand(duplicated), /Exactly one/) +}) + +test('physical A facts exclude skipped rows and unchanged owners/stock detect a repeated completion or deduction', () => { + const sample = fixture('A') + sample.task.items = sample.task.items.map(row => ({ ...row, itemStatus: { PARTIAL: 'PARTIAL', PICK: 'PICKED', SKIP: 'SKIPPED' }[sample.expected.find(item => item.pickId === row.id).action], + pickedPackageCount: sample.expected.find(item => item.pickId === row.id).actual })) + verifyPhysicalFacts(sample.expected, sample.task) + const drift = structuredClone(sample.task); drift.items[0].pickedPackageCount++ + assert.throws(() => verifyPhysicalFacts(sample.expected, drift), /drift/) + const before = { sources: [{ owner: { inboundId: '1' }, view: { summary: { id: '1', processStatus: 'LOADED', version: 12 }, statusHistory: [{ action: 'COMPLETE_LOADING' }], palletLocations: [] } }], inventory: { inbounds: [] } } + verifyOldOwnersUnchanged(before, structuredClone(before), sample.expected) + const repeated = structuredClone(before); repeated.sources[0].view.statusHistory.push({ action: 'COMPLETE_LOADING' }) + assert.throws(() => verifyOldOwnersUnchanged(before, repeated, sample.expected), /must not repeat/) + const deducted = structuredClone(before); deducted.inventory.inbounds.push({ availablePackageCount: 1 }) + assert.throws(() => verifyOldOwnersUnchanged(before, deducted, sample.expected), /deducted again/) +}) + +test('transit evidence uses raw balance identity and excludes transit from physical total, with no skipped source or duplicate ledger', () => { + const sample = fixture('A'); sample.source.containerNo = 'TEST-CONTAINER' + for (const row of sample.expected) { row.frozen.inboundCode = `IN-${row.inboundId}`; row.frozen.skuCode = `SKU-${row.itemId}` } + const snapshot = ['1','3','4'].map(inboundId => { + const active = sample.expected.filter(row => row.inboundId === inboundId && row.actual > 0), ownerRow = sample.expected.find(row => row.inboundId === inboundId) + const owner = { inboundId, tenantId: ownerRow.accountId }, count = active.reduce((sum,row) => sum + row.actual, 0) + if (!count) return { owner, orders: [], balances: [], ledger: [], details: [] } + const order = { id: `100${inboundId}`, orderNo: `LL-${inboundId}`, customerId: owner.tenantId, sourceInboundId: inboundId, + sourceContainerBookingId: sample.source.containerBookingId, containerNo: sample.source.containerNo, packageCount: count } + return { owner, orders: [order], details: [{ cargo: active.map(row => ({ skuCode: row.frozen.skuCode, packageCount: row.actual })), + statusHistory: [{ toStatus: 'PENDING_ASSIGNMENT', remark: '头程装柜完成自动生成' }] }], + balances: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, inTransitPackages: count, + totalPackages: 0, availablePackages: 0, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, procurementPackages: 0 }], + ledger: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, businessOrderNo: order.orderNo, + beforePackages: 0, deltaPackages: count, afterPackages: count }] } + }) + assert.equal(verifyDownstream(sample, snapshot).actualPackages, 3) + const doubled = structuredClone(snapshot); doubled[0].ledger.push(doubled[0].ledger[0]) + assert.throws(() => verifyDownstream(sample, doubled), /inventory movement/) + const grouped = structuredClone(snapshot); grouped[0].balances[0].inboundOrderId = null + assert.throws(() => verifyDownstream(sample, grouped), /transit inventory/) + const skipped = structuredClone(snapshot); skipped[2].orders.push(snapshot[0].orders[0]) + assert.throws(() => verifyDownstream(sample, skipped), /Skipped owner/) +}) + +test('manifest needs two ordered cases, unique actions and all five photos', () => { + const manifest = { schemaVersion: 1, runId, environment: 'TEST', cases: [fixture('A').spec, fixture('B').spec] } + assert.equal(validateManifest(manifest, runId), manifest) + const duplicate = structuredClone(manifest); duplicate.cases[1].actions[1].itemId = '1' + assert.throws(() => validateManifest(duplicate, runId), /unique/) + const missing = structuredClone(manifest); delete missing.cases[0].evidence.SEAL + assert.throws(() => validateManifest(missing, runId), /Five/) +}) + +test('official source must be fresh, passing, unclaimed and run-bound', () => { + const now = Date.parse('2026-10-04T12:00:00Z'), report = { success: true, fatal: null, summary: { failed: 0 }, gateway: 'https://test.invalid', + createdResources: { manualPdaBatchFixture: { ...fixture().source, schemaVersion: 1, stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', + preparedAt: new Date(now).toISOString(), taskItems: fixture().task.items } } } + assert.ok(fixtureSource(report, runId, report.gateway, now)) + assert.throws(() => fixtureSource(report, runId, report.gateway, now + 25 * 3600_000), /within/) + const old = structuredClone(report); old.createdResources.manualPdaBatchFixture.sourceMarker = 'old' + assert.throws(() => fixtureSource(old, runId, report.gateway, now), /run-bound/) + const failed = structuredClone(report); failed.summary.failed = 1 + assert.throws(() => fixtureSource(failed, runId, report.gateway, now), /did not pass/) +}) + +test('exact HALF_UP handles non-half ratios, ties, nullable metrics and invalid ratios', () => { + assert.equal(scale('10.001', 2, 3, 3), '6.667') + assert.equal(scale('8.002', 2, 3, 3), '5.335') + assert.equal(scale('0.1001', 2, 3, 4), '0.0667') + assert.equal(scale('1', 1, 2, 0), '1') + assert.equal(scale(null, 1, 2, 3), null) + assert.throws(() => scale('10', 4, 3, 3)) +}) + +test('A and B require the exact actual/skip shapes before any mutation', () => { + assert.equal(fixture('A').expected.filter(row => row.actual > 0).length, 2) + assert.ok(fixture('B').expected.filter(row => row.actual > 0).every(row => row.action === 'PARTIAL')) + const { spec, task, preload } = fixture('B') + spec.actions[0] = { itemId: '1', action: 'PICK' } + assert.throws(() => expectedRows(spec, task, preload), /exactly one/) + spec.actions[0] = { itemId: '1', action: 'PARTIAL', finalPickedPackageCount: 2 } + assert.throws(() => expectedRows(spec, task, preload), /less than/) +}) + +test('duplicate, missing, already picked and wrong-customer source rows reject preflight', () => { + const a = fixture('A'); a.spec.actions.pop() + assert.throws(() => expectedRows(a.spec, a.task, a.preload), /cover every/) + const b = fixture(); b.task.items[0].pickedPackageCount = 1 + assert.throws(() => expectedRows(b.spec, b.task, b.preload), /zero-picked/) + const c = fixture(); c.preload.items[0].accountId = '999' + assert.throws(() => expectedRows(c.spec, c.task, c.preload), /identity/) +}) + +test('actual scope, metrics and refund customer/coverage are independently checked', () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind) + assert.equal(verifyActual(sample.expected, sample.detail, sample.customs).actualRows, kind === 'A' ? 2 : 1) + const contaminated = structuredClone(sample.detail); contaminated.items.push(sample.preload.items.at(-1)) + assert.throws(() => verifyActual(sample.expected, contaminated, sample.customs), /Actual loading row set/) + const stale = structuredClone(sample.customs); stale.actualBoxCount++ + assert.throws(() => verifyActual(sample.expected, sample.detail, stale), /Customs actual boxes/) + const wrong = structuredClone(sample.customs); wrong.inbounds[0].accountId = '999' + assert.throws(() => verifyActual(sample.expected, sample.detail, wrong), /Customs source/) + } +}) + +function transport(sample, { drift = false, failCompletion = false, incomplete = false } = {}) { + const calls = [], task = structuredClone(sample.task), customs = structuredClone(sample.customs) + const history = [], pda = `/api/platform/v1/first-leg/pda-loading/tasks/${sample.source.pdaTaskId}` + let completes = 0 + return { calls, request: async (method, path, body, role, headers) => { + calls.push({ method, path, body: body instanceof FormData ? 'multipart' : structuredClone(body), role, headers }) + let data + if (method === 'POST' && path === `${pda}/claim`) { task.workflowStatus = 'IN_PROGRESS'; data = task } + else if (method === 'POST' && /\/items\/\d+\/skip$/.test(path)) { + const itemId = path.split('/').at(-2) + task.items = task.items.map(row => row.id === itemId ? { ...row, itemStatus: 'SKIPPED', pickedPackageCount: 0 } : row) + data = task + } + else if (method === 'POST' && path === `${pda}/batch`) { + task.workflowStatus = 'AWAITING_REVIEW'; task.items = task.items.map(row => { + const expected = sample.expected.find(item => item.pickId === row.id) + return { ...row, itemStatus: { PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' }[expected.action], pickedPackageCount: expected.actual } + }); data = task + } else if (method === 'POST' && path === `${pda}/evidence`) data = {} + else if (path.startsWith(`${pda}/evidence?`)) data = { evidences: Object.keys(evidence).map(type => ({ evidenceType: type, status: 'ACTIVE', sealNo: sample.spec.sealNo })) } + else if (method === 'POST' && path === `${pda}/complete`) { + completes++ + if (failCompletion) throw Error('Response lost') + task.taskStatus = incomplete ? 'COMPLETING' : 'COMPLETED'; task.workflowStatus = incomplete ? 'COMPLETING' : 'COMPLETED' + if (completes === 1) history.push({ actionType: 'COMPLETE_LOADING' }) + if (drift && completes > 1) task.version++ + data = task + } else if (path.startsWith(`${pda}/inventory-snapshot?`)) data = { snapshotToken: 'fresh-snapshot' } + else if (path.startsWith(`${pda}?`)) data = task + else if (path === `${pda}/operations`) data = history + else if (path.includes('/loading-lists/')) data = sample.detail + else if (path.endsWith('/send-to-broker')) return { path, http: 200, body: { code: 'A0409' } } + else if (path.endsWith('/start')) { customs.status = 'DECLARING'; customs.version++; data = customs } + else if (path.endsWith('/complete-all-buy')) { customs.status = 'COMPLETED'; customs.version++; data = customs } + else if (path.includes('/customs/loading-plans/')) data = customs + else throw Error(`Unexpected ${method} ${path}`) + return { path, http: 200, body: { code: '200', data: structuredClone(data) } } + } } +} + +test('both cases execute actual completion; B is all PARTIAL and follows all-buy gates', async () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind), http = transport(sample), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1,2,3]), type: 'image/png' }])) + const result = await runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}) + assert.equal(result.completed, true); assert.equal(result.allActualRowsPartial, kind === 'B') + const completes = http.calls.filter(call => call.path.endsWith('/complete')) + assert.equal(completes.length, 2); assert.deepEqual(completes[0].body, completes[1].body) + const batches = http.calls.filter(call => call.path.endsWith('/batch')) + assert.equal(batches.length, 1) + if (kind === 'A') { + const skipIndex = http.calls.findIndex(call => /\/items\/\d+\/skip$/.test(call.path)) + assert.ok(skipIndex >= 0 && skipIndex < http.calls.findIndex(call => call.path.endsWith('/batch'))) + assert.ok(!batches[0].body.items.some(item => item.itemId === '2'), 'Resolved same-owner SKIP does not repeat in full pending batch') + } + if (kind === 'B') assert.ok(http.calls.some(call => call.path.endsWith('/complete-all-buy'))) + } +}) + +test('replay version/history drift is a hard failure', async () => { + const sample = fixture(), http = transport(sample, { drift: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /zero projection/) +}) + +test('unknown completion result is never retried and no later customs write is attempted', async () => { + const sample = fixture(), http = transport(sample, { failCompletion: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /Response lost/) + assert.equal(http.calls.filter(call => call.path.endsWith('/complete')).length, 1) + assert.equal(http.calls.filter(call => call.method === 'POST' && call.path.includes('/customs/')).length, 0) +}) + +test('accepted completion without terminal state fails rather than claiming success', async () => { + const sample = fixture(), http = transport(sample, { incomplete: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}), /polling exhausted/) +})