From 80632ba409e63582c6286ce0a4cd89633c31fce0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 12:58:15 +0800 Subject: [PATCH] test: strengthen customs and fulfillment acceptance boundaries --- scripts/README.md | 2 + scripts/admin-test-env-modules/README.md | 4 +- ...ainer-customs-acceptance-contract.test.mjs | 127 ++++++ ...nt-qualification-fixture-contract.test.mjs | 26 ++ .../merchant-qualification-fixture.mjs | 14 +- .../phase3-container-customs-fixture.mjs | 157 ++++++- .../phase3-container-customs.mjs | 226 ++++++++++- .../phase3-loading-marker-contract.test.mjs | 29 +- .../admin-test-env-modules/phase3-loading.mjs | 21 +- ...se5-overseas-fulfillment-contract.test.mjs | 191 ++++++++- .../phase5-overseas-fulfillment.mjs | 382 +++++++++++++++++- scripts/event-delivery-fault-proxy.py | 296 ++++++++++++++ scripts/event-delivery-fault-proxy.test.mjs | 181 +++++++++ 13 files changed, 1616 insertions(+), 40 deletions(-) create mode 100644 scripts/event-delivery-fault-proxy.py create mode 100644 scripts/event-delivery-fault-proxy.test.mjs diff --git a/scripts/README.md b/scripts/README.md index 21641aa81..8b892e876 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id` 或 `--loading-plan-id`;`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 + `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 `db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4b97b0a51..cc8c75213 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -167,7 +167,7 @@ export async function run(context) { | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | -| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | +| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式客户资格、订单、测量、同柜装载和最终完成 API 生成平台公共订舱下的买单与两客户退税混合柜。第二客户独立登录,凭据只在内存;验证确认/打回隔离、正式申报各类人工异常及剩余 OPEN 门禁、每客户完成文件、自动出仓和唯一船期任务。客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | | 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | @@ -224,6 +224,8 @@ node scripts/admin-test-env-acceptance.mjs --gateway --modules ph 海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 +该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。 + ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment ``` diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index e96684265..bc499ab73 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs' import { CONTAINER_CUSTOMS_FIXTURE_KINDS, @@ -92,3 +93,129 @@ test('database evidence list covers atomicity, history, dispatch and outbox with 'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table) assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i) }) + +function runtimeContext() { + return { marker: 'OFFLINE-CUSTOMS', asserted: [], + assert(_module, name, _response, passed) { assert.ok(passed, name); this.asserted.push(name) }, + addCoverageGap() {}, addStep() {}, + } +} +const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } }) + +function formalAdapter({ prematurelyClose = false, invalidOtherWrites = false } = {}) { + const container = { loadingPlanId: '1', containerBookingId: '2', containerNo: 'TESTU1234567', + status: 'DECLARING', version: 8, brokerSubmittedAt: '2026-09-30T10:00:00', + documents: [{ documentId: '10', source: { fileId: '11' }, receiptStatus: 'CONFIRMED' }], inbounds: [] } + let batch = null + return { + platformDetail: async () => response(container), + platformCreateDeclaration: async () => { batch = { id: '100', status: 'READY', version: 0, results: [] }; return response(batch) }, + platformDeclarationBatches: async () => response([batch]), + platformSubmitDeclaration: async (_ctx, _fixture, _id, request) => { + assert.equal(request.expectedVersion, batch.version) + batch.status = 'SUBMITTED'; batch.version += 1; batch.customsEntryNo = request.customsEntryNo + return response(batch) + }, + platformRecordClearance: async (_ctx, _fixture, _id, request) => { + assert.equal(request.expectedVersion, batch.version) + if (request.resultType === 'OTHER' && !request.resolution) { + if (invalidOtherWrites) batch.results.push({ id: 'invalid', resultStatus: 'OPEN' }) + return response(null, 'A0400') + } + const result = { id: String(200 + batch.results.length), resultType: request.resultType, + resultStatus: request.resolved ? 'RESOLVED' : 'OPEN' } + batch.results.push(result) + batch.status = prematurelyClose && request.resolved ? 'CLOSED' + : batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED' + batch.version += 1 + return response(result) + }, + platformResolveClearance: async (_ctx, _fixture, resultId, request) => { + assert.equal(request.expectedVersion, batch.version) + const result = batch.results.find((item) => item.id === resultId) + result.resultStatus = 'RESOLVED'; batch.version += 1 + batch.status = batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED' + return response(result) + }, + platformClearanceResults: async () => response(batch.results), + } +} + +test('formal declaration runs every manual exception, retains OPEN gates and keeps customs document facts', async () => { + const ctx = runtimeContext(); const fixture = {} + await verifyFormalDeclarationAndExceptions(ctx, formalAdapter(), fixture) + assert.equal(fixture.declarationBatchId, '100') + assert.equal(fixture.clearanceResultIds.length, 5) + for (const type of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) { + assert.ok(ctx.asserted.includes(`平台人工标记海关异常${type}`)) + } + assert.ok(ctx.asserted.includes('海关人工处理保留客户资料回执确认原柜与报关四态')) +}) + +test('formal probe fails when a resolved result closes a batch with unresolved exceptions', async () => { + await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(), + formalAdapter({ prematurelyClose: true }), {}), /不能提前关闭申报批次/) +}) + +test('OTHER missing remark probe rejects hidden writes even when API returns expected error', async () => { + await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(), + formalAdapter({ invalidOtherWrites: true }), {}), /必须填写备注且批次结果零漂移/) +}) + +function mixedAdapter({ contaminateForeignConfirmation = false } = {}) { + const docs = [{ documentId: 'A', receipt: { version: 1, fileId: 'RA' }, receiptStatus: 'PENDING' }, + { documentId: 'B', receipt: null, receiptStatus: null }] + const container = { version: 2, status: 'DECLARING', documents: docs, brokerSubmittedAt: null } + const confirmations = new Set() + return { + platformDetail: async () => response(container), + platformUploadReceipt: async (_ctx, _fixture, request) => { + const doc = docs.find((item) => item.documentId === request.documentId) + assert.equal(request.expectedVersion, doc.receipt?.version || 0) + doc.receipt = { version: (doc.receipt?.version || 0) + 1, fileId: `R${doc.documentId}` } + doc.receiptStatus = 'PENDING' + return response(doc) + }, + merchantConfirmReceipt: async (_ctx, _fixture, customer, request, commandKey) => { + if (customer.documentId !== request.documentId) { + if (contaminateForeignConfirmation) docs[0].receiptStatus = 'PENDING' + return response(null, 'A0404') + } + const doc = docs.find((item) => item.documentId === request.documentId) + assert.equal(request.expectedVersion, doc.receipt.version) + if (!confirmations.has(commandKey)) { + doc.receiptStatus = request.confirm ? 'CONFIRMED' : 'REJECTED' + confirmations.add(commandKey) + } + return response(container) + }, + platformSubmitToBroker: async () => response(null, 'A0409'), + platformCreateDeclaration: async () => response(null, 'A0409'), + } +} +const mixedFixture = () => ({ customers: [{ documentId: 'A', customerId: '1' }, + { documentId: 'B', customerId: '2' }] }) + +test('multi-customer receipt flow rejects, replaces and confirms only the affected customer', async () => { + const ctx = runtimeContext() + const result = await verifyMixedReceiptIsolation(ctx, mixedAdapter(), mixedFixture(), + response({ receipt: { version: 1 } })) + assert.ok(result.body.data.documents.every((item) => item.receiptStatus === 'CONFIRMED')) + assert.ok(ctx.asserted.includes('未全部客户确认禁止发送报关行执行')) + assert.ok(ctx.asserted.includes('未全部确认禁止创建正式申报批次')) +}) + +test('cross-customer receipt denial also requires prior confirmation remain unchanged', async () => { + await assert.rejects(verifyMixedReceiptIsolation(runtimeContext(), + mixedAdapter({ contaminateForeignConfirmation: true }), mixedFixture(), + response({ receipt: { version: 1 } })), /第二客户不能确认其他客户回执且原确认保持/) +}) + +test('second customer credentials stay in adapter memory and are not exported with report resources', () => { + assert.match(fixtureSource, /publishEnvironment: false/) + assert.match(fixtureSource, /customerTokens\.get/) + assert.match(fixtureSource, /customers\.length < 2/) + const customerData = fixtureSource.slice(fixtureSource.indexOf('const customers ='), fixtureSource.indexOf('const buyInboundIds =')) + assert.doesNotMatch(customerData, /(?:sessionToken|accessToken|customerToken|password)/) + assert.doesNotMatch(fixtureSource, /\b(?:INSERT\s+INTO|DELETE\s+FROM|UPDATE\s+(?:fl_|ll_|wh_|ord_))/i) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index bda5d37e9..636c72864 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -1,11 +1,37 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { qualificationCreditCode } from './merchant-qualification-fixture.mjs' const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') +test('credit codes distinguish repeated CUSTOMER-B and SIGNED-B fixtures using the complete marker', () => { + const customerId = '2100000000000000001' + const values = [] + for (const ending of ['CUSTOMER-B', 'SIGNED-B']) { + const firstMarker = `ADMIN-API-20260930121500000-${ending}` + const secondMarker = `ADMIN-API-20260930121600000-${ending}` + const first = qualificationCreditCode(firstMarker, customerId) + const second = qualificationCreditCode(secondMarker, customerId) + assert.notEqual(first, second, `时间不同但后缀同为 ${ending} 必须生成不同代码`) + assert.equal(qualificationCreditCode(firstMarker, customerId), first) + for (const value of [first, second]) assert.match(value, /^91440300[0-9A-F]{10}$/) + values.push(first, second) + } + assert.equal(new Set(values).size, values.length) + assert.match(fixture, /unifiedSocialCreditCode = qualificationCreditCode\(context\.marker, tenantId\)/) +}) + +test('a new real customer under the same marker receives a distinct stable code', () => { + const marker = 'ADMIN-API-20260930121500000-CUSTOMER-B' + assert.notEqual(qualificationCreditCode(marker, '2100000000000000001'), + qualificationCreditCode(marker, '2100000000000000002')) + assert.throws(() => qualificationCreditCode('', '2100000000000000001'), /完整批次标识/) + assert.throws(() => qualificationCreditCode(marker, ''), /真实客户编号/) +}) + test('默认全量验收先准备已审核商户再运行订单链路', () => { const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' assert.match(modules, /^'merchant-qualification-fixture', 'customers'/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index b754e0466..07fa6f343 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + const MODULE = 'merchant-qualification-fixture' const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID']) const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from( @@ -9,6 +11,16 @@ function id(value) { return value === undefined || value === null ? '' : String(value) } +export function qualificationCreditCode(marker, tenantId) { + const customerId = id(tenantId) + if (!String(marker || '').trim() || !customerId) { + throw new Error('测试资质代码必须使用完整批次标识和本轮真实客户编号') + } + const suffix = createHash('sha256').update(JSON.stringify([String(marker), customerId])) + .digest('hex').slice(0, 10).toUpperCase() + return `91440300${suffix}` +} + async function waitForDimensionInitialization(context, tenantId) { let response = null let status = 'PENDING' @@ -62,7 +74,7 @@ export async function run(context, { publishEnvironment = true } = {}) { const packageForm = new FormData() packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' })) packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' })) - const unifiedSocialCreditCode = `91440300${suffix}` + const unifiedSocialCreditCode = qualificationCreditCode(context.marker, tenantId) const packageResponse = await context.requestWithBody('POST', `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`, packageForm) diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 0d92e3282..5507e1853 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -50,7 +50,8 @@ async function loginMerchant(context) { return token } -function routes(context, merchantToken) { +function routes(context, merchantToken, customerTokens = new Map()) { + const tokenFor = (customer) => customerTokens.get(id(customer.customerId)) || merchantToken const binary = (method, path, token = context.token) => raw(context, method, path, undefined, token) const multipart = (path, fields, file, token, idempotencyKey) => { const form = new FormData() @@ -64,27 +65,27 @@ function routes(context, merchantToken) { return { version: 1, platformDetail: (ctx, value) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(value.loadingPlanId))}`), - merchantDetail: (ctx, fixture, customer, token = merchantToken) => ctx.request('GET', + merchantDetail: (ctx, fixture, customer, token = tokenFor(customer)) => ctx.request('GET', `${MERCHANT}/orders/${encodeURIComponent(id(customer.orderId))}`, undefined, token), merchantPlatformDetail: (ctx, fixture) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}`, undefined, merchantToken), merchantUploadDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart( `${MERCHANT}/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/documents/upload?${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`, - {}, file, customer.customerToken || merchantToken, idempotencyKey), + {}, file, tokenFor(customer), idempotencyKey), merchantReplaceDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart( `${MERCHANT}/documents/${encodeURIComponent(id(file.documentId))}/upload?expectedVersion=${Number(file.expectedVersion)}&${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`, - {}, file, customer.customerToken || merchantToken, idempotencyKey), + {}, file, tokenFor(customer), idempotencyKey), merchantUpdateScope: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('PUT', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/inbounds`, { inboundIds: request.inboundIds.map(id), expectedVersion: Number(request.expectedVersion), - }, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + }, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`, - undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + undefined, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, { expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' }, - customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + tokenFor(customer), { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), @@ -97,6 +98,22 @@ function routes(context, merchantToken) { platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), + platformDeclarationBatches: (ctx, fixture) => ctx.request('GET', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`), + platformCreateDeclaration: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformSubmitDeclaration: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/submit`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformClearanceResults: (ctx, fixture, batchId) => ctx.request('GET', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`), + platformRecordClearance: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), + platformResolveClearance: (ctx, fixture, resultId, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/clearance-results/${encodeURIComponent(id(resultId))}/resolve`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), @@ -109,10 +126,10 @@ function routes(context, merchantToken) { `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, - customer.customerToken || merchantToken), + tokenFor(customer)), merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, - customer.customerToken || merchantToken), + tokenFor(customer)), buyDeclarationXlsx: async (ctx, request) => { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', @@ -150,6 +167,98 @@ function routes(context, merchantToken) { } } +async function addSecondRefundCustomer(context) { + const source = context.createdResources.phase3Loading + const secondary = Object.create(context) + secondary.marker = `${context.marker}-CUSTOMER-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context, orderProfile: { + ...(context.context.orderProfile || {}), taxMode: 'TAX_REFUND', fulfillmentType: 'STANDARD', + }, orderFulfillmentType: 'STANDARD' } + secondary.createdResources = {} + const qualification = await import('./merchant-qualification-fixture.mjs') + await qualification.run(secondary, { publishEnvironment: false }) + const account = secondary.context.merchantQualificationFixture + // orders.mjs captures its credentials at import time. A distinct module instance captures + // this customer's credentials without changing the primary customer's cached producer. + const saved = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD'].map((name) => + [name, Object.hasOwn(process.env, name), process.env[name]]) + let orders + try { + process.env.MERCHANT_TEST_USERNAME = account.username + process.env.MERCHANT_TEST_PASSWORD = account.password + orders = await import(`./orders.mjs?customsCustomer=${encodeURIComponent(account.tenantId)}`) + } finally { + for (const [name, present, value] of saved) { + if (present) process.env[name] = value + else delete process.env[name] + } + } + await secondary.executeModule(`${MODULE}-customer-b-orders`, () => orders.run(secondary)) + const measurement = await import('./inbound-measurement.mjs') + await secondary.executeModule(`${MODULE}-customer-b-measurement`, () => measurement.run(secondary)) + const inbound = secondary.createdResources.inboundMeasurement + if (!inbound?.inboundId || id(inbound.tenantId) !== id(account.tenantId) + || id(inbound.tenantId) === id(source.tenantId)) throw new Error('第二退税客户未生成独立入库单') + const login = await secondary.request('POST', '/api/auth/login', { + loginId: account.username, password: account.password, + }, '') + if (!success(login) || !login.body?.data?.accessToken) throw new Error('第二退税客户登录失败') + const smart = '/api/platform/v1/first-leg/smart-loading' + const candidatesResponse = await context.request('GET', `${smart}/candidates?${new URLSearchParams({ + bookingId: id(source.containerBookingId), warehouseId: id(source.warehouseId), + })}`) + const candidates = context.apiSuccess(MODULE, '第二退税客户读取原柜正式可装候选', candidatesResponse, '', OPTIONS) + const candidate = (Array.isArray(candidates) ? candidates : candidates?.records || []).find((item) => + id(item.inboundId) === id(inbound.inboundId) && id(item.accountId) === id(account.tenantId) + && item.inboundDetailId && Number(item.availablePackageCount) > 0) + if (!candidate) throw new Error('第二退税客户在原柜没有正式可装候选,禁止借历史票') + const planResponse = await context.request('GET', `${smart}/plans/${id(source.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '加入第二客户前回查原柜计划', planResponse, '', OPTIONS) + const assignedResponse = await context.request('POST', `${smart}/plans/${id(source.loadingPlanId)}/assignments`, { + inboundId: id(inbound.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(plan.version), operationId: key(context, 'customer-b-assign'), + }) + const assigned = context.apiSuccess(MODULE, '第二退税客户正式分配到同柜', assignedResponse, '', OPTIONS) + const item = assigned?.items?.find((value) => id(value.inboundId) === id(inbound.inboundId)) + const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${id(source.canonicalPdaTaskId)}` + const taskResponse = await context.request('GET', `${taskPath}?warehouseId=${id(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, '同柜新增第二客户后回查原PDA任务', taskResponse, '', OPTIONS) + const taskItem = task?.items?.find((value) => id(value.loadingPlanItemId) === id(item?.id)) + const inventoryResponse = await context.request('GET', `${taskPath}/inventory-snapshot?warehouseId=${id(source.warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '第二客户刷新正式拣货库存快照', inventoryResponse, '', OPTIONS) + if (!taskItem || !inventory?.snapshotToken) throw new Error('第二客户缺少正式PDA明细或库存快照') + const pickedResponse = await context.request('POST', `${taskPath}/batch`, { + warehouseId: id(source.warehouseId), loadingPlanId: id(source.loadingPlanId), + containerBookingId: id(source.containerBookingId), expectedVersion: Number(task.version), + inventorySnapshotToken: inventory.snapshotToken, + items: [{ itemId: id(taskItem.id), action: 'PICK', pickedPackageCount: 1, version: Number(taskItem.version) }], + }, context.pdaToken || context.token, { 'Idempotency-Key': key(context, 'customer-b-pick') }) + const picked = context.apiSuccess(MODULE, '同柜第二客户完成正式PDA拣货', pickedResponse, '', OPTIONS) + context.assert(MODULE, '第二客户加入后原柜原任务保持且恢复待完成', pickedResponse, + id(picked?.id) === id(source.canonicalPdaTaskId) && picked?.workflowStatus === 'AWAITING_REVIEW' + && id(picked?.loadingPlanId) === id(source.loadingPlanId) + && id(picked?.containerBookingId) === id(source.containerBookingId), + `workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) + source.pdaTaskVersion = Number(picked?.version) + const { stableLoadedCartonMarks } = await import('./phase3-loading.mjs') + source.cartonMarks = stableLoadedCartonMarks(picked?.items) + return { customerId: id(account.tenantId), sessionToken: login.body.data.accessToken } +} + +export async function withPlatformBookingScope(context, runLoading) { + const name = 'phase3LoadingBookingScope' + const present = Object.hasOwn(context.context, name) + const previous = context.context[name] + context.context[name] = 'PLATFORM' + try { + return await runLoading() + } finally { + if (present) context.context[name] = previous + else delete context.context[name] + } +} + export async function prepare(context) { const outerMarker = context.marker const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] @@ -165,6 +274,7 @@ export async function prepare(context) { const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, { primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND', }) + try { const executionMarker = `${outerMarker}-CUSTOMS-MIXED` context.marker = executionMarker if (context.createdResources?.orchestrationFixtures?.phase3Loading) { @@ -174,7 +284,9 @@ export async function prepare(context) { context.createdResources.orchestrationFixtures.phase3LoadingIncremental.marker = executionMarker } const loading = await import('./phase3-loading.mjs') - await context.executeModule(`${MODULE}-loading`, () => loading.run(context)) + await withPlatformBookingScope(context, () => + context.executeModule(`${MODULE}-loading`, () => loading.run(context))) + const secondCustomer = await addSecondRefundCustomer(context) const finalize = await import('./phase3-loading-finalize.mjs') await context.executeModule(`${MODULE}-finalize`, () => finalize.run(context)) @@ -202,10 +314,11 @@ export async function prepare(context) { throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`) } const merchantToken = await loginMerchant(context) - const customerId = id(refund[0].tenantId) - const customer = { - customerId, orderId: id(refund[0].orderId), - inboundIds: refund.map((item) => id(item.inboundId)), + const customers = [...new Set(refund.map((item) => id(item.tenantId)))].map((customerId) => { + const own = refund.filter((item) => id(item.tenantId) === customerId) + return { + customerId, orderId: id(own[0].orderId), + inboundIds: own.map((item) => id(item.inboundId)), document: { fileName: `${context.marker}-tax-refund.customblob`, contentType: 'application/octet-stream', content: Uint8Array.from([0x54, 0x41, 0x58]), @@ -214,8 +327,9 @@ export async function prepare(context) { fileName: `${context.marker}-tax-refund-v2.customblob`, contentType: 'application/octet-stream', content: Uint8Array.from([0x54, 0x41, 0x58, 0x32]), }, - updatedInboundIds: refund.map((item) => id(item.inboundId)), - } + updatedInboundIds: own.map((item) => id(item.inboundId)), + } }) + if (customers.length < 2) throw new Error('同柜未形成两个实际退税客户') const buyInboundIds = new Set(buy.map((item) => id(item.inboundId))) const planBuyRows = planItems.filter((item) => buyInboundIds.has(id(item.inboundId))).length const expectedBuyRows = planBuyRows > 0 ? planBuyRows : buy.length @@ -230,14 +344,21 @@ export async function prepare(context) { inboundId: id(item.inboundId), orderId: id(item.orderId), customerId: id(item.tenantId), inboundCode: item.inboundCode, taxMode: item.taxMode, })), - customers: [customer], expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status, + customers, expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status, }], } - context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken) + context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken, new Map([[secondCustomer.customerId, secondCustomer.sessionToken]])) return () => { delete context.context.phase3ContainerCustomsRoutes context.marker = outerMarker restore() restoreIsolatedResources() } + } catch (error) { + delete context.context.phase3ContainerCustomsRoutes + context.marker = outerMarker + restore() + restoreIsolatedResources() + throw error + } } diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index 108fbc619..c51dabad5 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -33,6 +33,11 @@ function resolvePreparation(context) { if (!validateRouteContract(routes)) { blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求') } + if (requestedKinds.includes('MIXED') && ['platformDeclarationBatches', 'platformCreateDeclaration', + 'platformSubmitDeclaration', 'platformClearanceResults', 'platformRecordClearance', + 'platformResolveClearance'].some((name) => typeof routes[name] !== 'function')) { + blocker(context, '正式申报与人工海关结果HTTP契约', '缺少已核对Controller的正式申报或海关结果适配器') + } return { fixtures, routes, requestedKinds } } @@ -260,6 +265,168 @@ async function runRefund(context, routes, fixture) { } +export function customsDocumentFacts(container) { + return { loadingPlanId: id(container?.loadingPlanId), containerBookingId: id(container?.containerBookingId), + containerNo: container?.containerNo, status: container?.status, + documents: container?.documents || [], inbounds: container?.inbounds || [], + brokerSubmittedAt: container?.brokerSubmittedAt, brokerSubmission: container?.brokerSubmission } +} + +export async function verifyMixedReceiptIsolation(context, routes, fixture, firstReceipt) { + const [first, other] = fixture.customers + const receiptVersion = Number(firstReceipt.body?.data?.receipt?.version) + const firstConfirmed = await routes.merchantConfirmReceipt(context, fixture, first, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + const firstReplay = await routes.merchantConfirmReceipt(context, fixture, first, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + context.assert(MODULE, '客户确认回执后仍未完成整柜', firstReplay, + success(firstConfirmed) && success(firstReplay) && firstReplay.body?.data?.status === 'DECLARING', + evidence(firstReplay), OPTIONS) + const beforeOther = await platformDetail(context, routes, fixture) + const confirmedFirst = beforeOther.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId) + for (const customer of fixture.customers.slice(1)) { + const uploaded = await routes.platformUploadReceipt(context, fixture, { + documentId: customer.documentId, expectedVersion: 0, + fileName: `${context.marker}-${customer.customerId}-receipt.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x32]), + }, key(context, `receipt-${customer.customerId}`)) + context.assert(MODULE, '第二客户报关回执独立上传', uploaded, success(uploaded), evidence(uploaded), OPTIONS) + } + const foreignConfirm = await routes.merchantConfirmReceipt(context, fixture, other, { + documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'foreign-receipt-confirm')) + const foreignBefore = await platformDetail(context, routes, fixture) + context.assert(MODULE, '第二客户不能确认其他客户回执且原确认保持', foreignConfirm, + foreignConfirm.http === 200 && ['A0403', 'A0404'].includes(code(foreignConfirm)) + && zeroDrift(confirmedFirst, foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(foreignConfirm), OPTIONS) + const otherDocument = foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId) + const rejected = await routes.merchantConfirmReceipt(context, fixture, other, { + documentId: other.documentId, expectedVersion: Number(otherDocument?.receipt?.version), + confirm: false, remark: `${context.marker}-回执资料需修改`, + }, key(context, 'mixed-other-reject')) + const afterReject = await platformDetail(context, routes, fixture) + const rejectedDocument = afterReject.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId) + context.assert(MODULE, '第二客户打回不撤销首个客户整份确认', rejected, + success(rejected) && rejectedDocument?.receiptStatus === 'REJECTED' + && zeroDrift(confirmedFirst, afterReject.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(rejected), OPTIONS) + const earlyExecution = await routes.platformSubmitToBroker(context, fixture, { + expectedVersion: Number(afterReject.body?.data?.version), confirm: true, remark: context.marker, + }, key(context, 'mixed-unconfirmed-execution')) + await assertZeroDrift(context, '未全部客户确认禁止发送报关行执行', routes, fixture, afterReject, earlyExecution) + const earlyDeclaration = await routes.platformCreateDeclaration(context, fixture, { + expectedVersion: Number(afterReject.body?.data?.version), remark: context.marker, + }, key(context, 'mixed-unconfirmed-declaration')) + await assertZeroDrift(context, '未全部确认禁止创建正式申报批次', routes, fixture, afterReject, earlyDeclaration) + const replacement = await routes.platformUploadReceipt(context, fixture, { + documentId: other.documentId, expectedVersion: Number(rejectedDocument?.receipt?.version), + fileName: `${context.marker}-${other.customerId}-receipt-v2.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x33]), + }, key(context, 'mixed-other-receipt-replacement')) + const afterReplacement = await platformDetail(context, routes, fixture) + context.assert(MODULE, '替换打回客户回执只重置该客户待确认且保留他人确认', replacement, + success(replacement) && replacement.body?.data?.receiptStatus === 'PENDING' + && zeroDrift(confirmedFirst, afterReplacement.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)), + evidence(replacement), OPTIONS) + for (const customer of fixture.customers.slice(1)) { + const current = await platformDetail(context, routes, fixture) + const document = current.body?.data?.documents?.find((item) => id(item.documentId) === customer.documentId) + const confirmation = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: Number(document?.receipt?.version), + confirm: true, remark: context.marker, + }, key(context, `mixed-other-confirm-${customer.customerId}`)) + context.assert(MODULE, '剩余客户确认自己当前整份回执', confirmation, success(confirmation), evidence(confirmation), OPTIONS) + } + const confirmed = await platformDetail(context, routes, fixture) + context.assert(MODULE, '同柜全部客户确认齐全仍等待平台发送执行', confirmed, + success(confirmed) && confirmed.body?.data?.documents?.every((item) => item.receiptStatus === 'CONFIRMED') + && confirmed.body?.data?.brokerSubmittedAt == null && confirmed.body?.data?.status === 'DECLARING', + evidence(confirmed), OPTIONS) + return confirmed +} + +export async function verifyFormalDeclarationAndExceptions(context, routes, fixture) { + const before = await platformDetail(context, routes, fixture) + const baseline = customsDocumentFacts(before.body?.data) + const created = await routes.platformCreateDeclaration(context, fixture, { + expectedVersion: Number(before.body?.data?.version), batchNo: `${context.marker}-DECL`.slice(-64), remark: context.marker, + }, key(context, 'formal-create')) + context.assert(MODULE, '平台人工创建正式申报批次', created, + success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS) + if (!success(created)) blocker(context, '正式申报批次', '创建正式申报批次失败,保留资料并停止结果写入') + const batchId = id(created.body.data.id) + const batches = () => routes.platformDeclarationBatches(context, fixture) + const batch = async () => { + const response = await batches() + if (!success(response)) blocker(context, '正式申报批次回查', evidence(response)) + const value = response.body.data.find((item) => id(item.id) === batchId) + if (!value) blocker(context, '正式申报批次回查', '本轮批次不在当前柜列表') + return value + } + const submitted = await routes.platformSubmitDeclaration(context, fixture, batchId, { + expectedVersion: Number(created.body.data.version), customsEntryNo: `${context.marker}-ENTRY`.slice(-64), remark: context.marker, + }, key(context, 'formal-submit')) + context.assert(MODULE, '人工回填报关单号提交正式申报', submitted, + success(submitted) && submitted.body?.data?.status === 'SUBMITTED' + && Boolean(submitted.body?.data?.customsEntryNo), evidence(submitted), OPTIONS) + if (!success(submitted)) blocker(context, '正式申报提交', '正式提交失败,禁止继续人工录入海关结果') + const local = new Date() + const occurredAt = `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}T${String(local.getHours()).padStart(2, '0')}:${String(local.getMinutes()).padStart(2, '0')}:${String(local.getSeconds()).padStart(2, '0')}` + const missingRemarkBatch = await batch() + const invalidOther = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(missingRemarkBatch.version), resultType: 'OTHER', occurredAt, resolution: '', resolved: false, + }, key(context, 'other-missing-remark')) + const afterInvalid = await batch() + context.assert(MODULE, '海关其他异常必须填写备注且批次结果零漂移', invalidOther, + invalidOther.http === 200 && code(invalidOther) === 'A0400' && zeroDrift(missingRemarkBatch, afterInvalid), + evidence(invalidOther), OPTIONS) + const opened = [] + for (const resultType of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) { + const current = await batch() + const recorded = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(current.version), resultType, occurredAt, + resolution: `${context.marker}-${resultType}-人工记录`, resolved: false, + }, key(context, `clearance-${resultType}`)) + context.assert(MODULE, `平台人工标记海关异常${resultType}`, recorded, + success(recorded) && recorded.body?.data?.resultType === resultType && recorded.body?.data?.resultStatus === 'OPEN', + evidence(recorded), OPTIONS) + if (!success(recorded)) blocker(context, '人工海关异常', `${resultType}保存失败`) + opened.push(id(recorded.body.data.id)) + } + const pending = await batch() + const released = await routes.platformRecordClearance(context, fixture, batchId, { + expectedVersion: Number(pending.version), resultType: 'RELEASED', occurredAt, + resolution: `${context.marker}-人工录入放行短信`, resolved: true, + }, key(context, 'clearance-released-with-open')) + const stillPending = await batch() + context.assert(MODULE, '已有未解除异常时新增已处理结果不能提前关闭申报批次', released, + success(released) && stillPending.status === 'RESULT_PENDING' + && stillPending.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length, + evidence(released), OPTIONS) + for (const [index, resultId] of opened.entries()) { + const current = await batch() + const resolved = await routes.platformResolveClearance(context, fixture, resultId, { + expectedVersion: Number(current.version), resolution: `${context.marker}-平台人工确认解除`, + }, key(context, `clearance-resolve-${resultId}`)) + const after = await batch() + context.assert(MODULE, `人工解除海关异常并保留剩余门禁${index + 1}`, resolved, + success(resolved) && resolved.body?.data?.resultStatus === 'RESOLVED' + && after.status === (index === opened.length - 1 ? 'CLOSED' : 'RESULT_PENDING') + && after.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length - index - 1, + evidence(resolved), OPTIONS) + } + const results = await routes.platformClearanceResults(context, fixture, batchId) + const after = await platformDetail(context, routes, fixture) + context.assert(MODULE, '海关人工处理保留客户资料回执确认原柜与报关四态', after, + success(results) && results.body?.data?.length === 5 && results.body.data.every((item) => item.resultStatus === 'RESOLVED') + && success(after) && zeroDrift(baseline, customsDocumentFacts(after.body?.data)), evidence(after), OPTIONS) + fixture.declarationBatchId = batchId + fixture.clearanceResultIds = [...opened, id(released.body?.data?.id)] +} + async function runMixedConcurrency(context, routes, fixture) { const initial = await platformDetail(context, routes, fixture) const initialData = initial.body?.data @@ -278,7 +445,7 @@ async function runMixedConcurrency(context, routes, fixture) { if (!customer) blocker(context, '混合柜客户fixture', '缺少本轮退税客户与正式登录凭据') const merchant = await routes.merchantDetail(context, fixture, customer, customer.customerToken) context.assert(MODULE, 'Client只见本客户TAX票且完全隐藏BUY票', merchant, - success(merchant) && merchant.body?.data?.inbounds?.length === refund.length + success(merchant) && merchant.body?.data?.inbounds?.length === customer.inboundIds.length && merchant.body.data.inbounds.every((item) => item.taxMode === 'TAX_REFUND') && !merchant.body.data.inbounds.some((item) => fixture.buyInboundIds.includes(id(item.inboundId))), evidence(merchant), OPTIONS) @@ -303,7 +470,7 @@ async function runMixedConcurrency(context, routes, fixture) { const uploadKey = key(context, 'mixed-tax-upload') const uploaded = await routes.merchantUploadDocument(context, fixture, customer, customer.document, uploadKey) context.assert(MODULE, 'Client上传资料只覆盖本柜TAX票', uploaded, - success(uploaded) && uploaded.body?.data?.inboundIds?.length === refund.length + success(uploaded) && uploaded.body?.data?.inboundIds?.length === customer.inboundIds.length && uploaded.body.data.inboundIds.every((value) => customer.inboundIds.includes(id(value))) && !uploaded.body.data.inboundIds.some((value) => fixture.buyInboundIds.includes(id(value))), evidence(uploaded), OPTIONS) @@ -314,6 +481,21 @@ async function runMixedConcurrency(context, routes, fixture) { customer.documentId = id(uploaded.body?.data?.documentId) customer.documentVersion = Number(uploaded.body?.data?.source?.version) + if (fixture.customers.length < 2) blocker(context, '同柜多退税客户', '必须由本轮正式装柜形成两个独立退税客户') + for (const other of fixture.customers.slice(1)) { + const isolated = await routes.merchantDetail(context, fixture, other) + context.assert(MODULE, '第二客户只见自身退税入仓票', isolated, + success(isolated) && isolated.body?.data?.inbounds?.length === other.inboundIds.length + && isolated.body.data.inbounds.every((item) => other.inboundIds.includes(id(item.inboundId))), + evidence(isolated), OPTIONS) + const otherUpload = await routes.merchantUploadDocument(context, fixture, other, other.document, + key(context, `mixed-tax-upload-${other.customerId}`)) + context.assert(MODULE, '第二退税客户独立上传资料', otherUpload, + success(otherUpload) && zeroDrift(otherUpload.body?.data?.inboundIds, other.inboundIds), evidence(otherUpload), OPTIONS) + if (!success(otherUpload)) blocker(context, '第二客户退税资料', '本轮第二客户资料上传失败,禁止继续开始') + other.documentId = id(otherUpload.body.data.documentId) + } + const ready = await platformDetail(context, routes, fixture) context.assert(MODULE, 'TAX票全部覆盖后平台准确进入READY_TO_START', ready, success(ready) && ready.body?.data?.status === 'READY_TO_START' @@ -372,22 +554,12 @@ async function runMixedConcurrency(context, routes, fixture) { const receiptDifferent = await routes.platformUploadReceipt(context, fixture, { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey) context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS) - const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0) - const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { - documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, - }, key(context, 'mixed-receipt-confirm')) - const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { - documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, - }, key(context, 'mixed-receipt-confirm')) - context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm, - success(receiptConfirm) && success(receiptConfirmReplay) - && receiptConfirm.body?.data?.status === 'DECLARING' - && receiptConfirmReplay.body?.data?.status === 'DECLARING', - [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) + const receiptConfirm = await verifyMixedReceiptIsolation(context, routes, fixture, receipt) const submitted = await routes.platformSubmitToBroker(context, fixture, { expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker')) context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + await verifyFormalDeclarationAndExceptions(context, routes, fixture) const finalRequest = { documentId: customer.documentId, expectedVersion: 0, fileName: `${context.marker}-mixed-customs-tax-final.pdf`, @@ -408,6 +580,19 @@ async function runMixedConcurrency(context, routes, fixture) { { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) + const partiallyCompleted = await platformDetail(context, routes, fixture) + context.assert(MODULE, '首个客户完成文件不能提前完成多客户整柜', partiallyCompleted, + success(partiallyCompleted) && partiallyCompleted.body?.data?.status === 'DECLARING', evidence(partiallyCompleted), OPTIONS) + for (const other of fixture.customers.slice(1)) { + const otherFinal = await routes.platformUploadFinal(context, fixture, { + documentId: other.documentId, expectedVersion: 0, + fileName: `${context.marker}-${other.customerId}-final.customblob`, + contentType: 'application/octet-stream', content: Uint8Array.from([0x46, 0x32]), + }, key(context, `mixed-final-${other.customerId}`)) + context.assert(MODULE, '其他客户真实完成文件独立回传', otherFinal, + success(otherFinal) && zeroDrift(otherFinal.body?.data?.inboundIds, other.inboundIds), evidence(otherFinal), OPTIONS) + } + let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) @@ -451,6 +636,19 @@ async function runMixedConcurrency(context, routes, fixture) { context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload, completionDownload.http === 200 && completionDownload.bytes?.length > 0, `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) + for (const other of fixture.customers.slice(1)) { + const ownDetail = await routes.merchantDetail(context, fixture, other) + context.assert(MODULE, '整柜完成后第二客户详情只包含自身资料与完成文件', ownDetail, + success(ownDetail) && ownDetail.body?.data?.documents?.length === 1 + && ownDetail.body.data.documents.every((item) => id(item.tenantId) === other.customerId + && id(item.documentId) === other.documentId && item.finalDocument?.fileId), + evidence(ownDetail), OPTIONS) + const foreignDownload = await routes.merchantDownloadFinal(context, fixture, other, { + documentId: customer.documentId, + }) + context.assert(MODULE, '第二客户不能下载其他客户完成文件', foreignDownload, + foreignDownload.http === 200 && code(foreignDownload) === 'A0404', evidence(foreignDownload), OPTIONS) + } } export async function run(context) { diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs index ec85dd985..34b9ee4aa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -1,7 +1,33 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { markerSuffix } from './phase3-loading.mjs' +import { loadingBookingCustomerFields, markerSuffix } from './phase3-loading.mjs' +import { withPlatformBookingScope } from './phase3-container-customs-fixture.mjs' + +test('normal loading keeps customer scope; explicit platform booking omits both customer fields', () => { + const fields = loadingBookingCustomerFields(undefined, '2100000000000000001', '测试客户') + assert.deepEqual(fields, { customerId: '2100000000000000001', customerName: '测试客户' }) + assert.deepEqual(loadingBookingCustomerFields('CUSTOMER', fields.customerId, fields.customerName), fields) + const platform = loadingBookingCustomerFields('PLATFORM', fields.customerId, fields.customerName) + assert.equal(Object.hasOwn(platform, 'customerId'), false) + assert.equal(Object.hasOwn(platform, 'customerName'), false) + assert.throws(() => loadingBookingCustomerFields('MISSPELLED', fields.customerId, fields.customerName), /订舱范围/) +}) + +test('mixed fixture platform scope restores absence and an existing value after success or failure', async () => { + const absent = { context: {} } + assert.equal(await withPlatformBookingScope(absent, async () => { + assert.equal(absent.context.phase3LoadingBookingScope, 'PLATFORM') + return 'loaded' + }), 'loaded') + assert.equal(Object.hasOwn(absent.context, 'phase3LoadingBookingScope'), false) + const existing = { context: { phase3LoadingBookingScope: 'CUSTOMER' } } + await assert.rejects(withPlatformBookingScope(existing, async () => { + assert.equal(existing.context.phase3LoadingBookingScope, 'PLATFORM') + throw new Error('actual loading failure') + }), /actual loading failure/) + assert.equal(existing.context.phase3LoadingBookingScope, 'CUSTOMER') +}) test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => { const base = 'ADMIN-API-20260824123024536' @@ -18,6 +44,7 @@ test('phase3 loading uses the collision-safe marker suffix for generated busines const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) assert.match(source, /const suffix = markerSuffix\(context\.marker\)/) + assert.match(source, /const bookingDraft = \{\s*\.\.\.loadingBookingCustomerFields\(context\.context\?\.phase3LoadingBookingScope, tenantId,/) assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/) assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/) assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 024401f5c..93cd2d6af 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -13,6 +13,11 @@ const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } +export function loadingBookingCustomerFields(scope = 'CUSTOMER', customerId, customerName) { + if (scope === 'PLATFORM') return {} + if (scope !== 'CUSTOMER') throw new Error('三期订舱范围必须明确为 CUSTOMER 或 PLATFORM') + return { customerId, customerName } +} export function markerSuffix(marker) { const value = String(marker || '') const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0') @@ -201,7 +206,8 @@ export async function run(context) { supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true, } const bookingDraft = { - customerId: tenantId, customerName: initial?.summary?.customerName, + ...loadingBookingCustomerFields(context.context?.phase3LoadingBookingScope, tenantId, + initial?.summary?.customerName), warehouseId, channelCode: channelGroup(initial?.summary?.channelCode), billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), @@ -569,6 +575,13 @@ export async function run(context) { { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) }) const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS) + if (context.context?.phase3LoadingBookingScope === 'PLATFORM') { + const platformScoped = bookingDraftResponse?.oceanBooking?.customerId == null + && bookingDraftResponse?.oceanBooking?.customerName == null + context.assert(MODULE, '多客户混柜使用未绑定单一客户的平台订舱', bookingCreateResponse, + platformScoped, 'scope=PLATFORM', OPTIONS) + if (!platformScoped) return + } const bookingId = id(bookingDraftResponse?.oceanBooking?.id) const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version) context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse, @@ -646,6 +659,12 @@ export async function run(context) { && id(linked?.oceanBookingId) === bookingId, `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) if (id(linked?.id) !== containerBookingId) return + if (context.context?.phase3LoadingBookingScope === 'PLATFORM') { + const platformScoped = linked?.customerId == null && linked?.customerName == null + context.assert(MODULE, '平台订舱关联后原柜保持跨客户装柜范围', linkResponse, + platformScoped, 'scope=PLATFORM', OPTIONS) + if (!platformScoped) return + } context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse, bookingConfirmed?.oceanBooking?.status === 'RELEASED' diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 513e079e5..14c32d701 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test' -import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs' +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion, + completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -114,3 +115,191 @@ test('manual exception retries only a transport failure with the identical busin assert.match(source, /人工异常连接失败后使用同载荷重试/) assert.match(source, /manualExceptionBody/) }) + +test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => { + const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3, + holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' } + assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }])) + for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) { + assert.notDeepEqual(inventoryQuantitySnapshot([original]), + inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }])) + } +}) + +function contextFor(request) { + const assertions = [] + const calls = [] + return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {}, + assertions, calls, + async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) }, + apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data }, + assert(module, name, response, passed) { assertions.push({ name, passed }) }, + addStep(module, name, response, passed) { assertions.push({ name, passed }) }, + addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) }, + } +} +const ok = data => ({ http: 200, body: { code: '200', data } }) + +test('partial completion rejects different Orders and an already delivered pending fragment', async () => { + for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' }, + { orderId: 'ORDER', processStatus: 'DELIVERED' }]) { + const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A') + ? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }] + : [{ inboundCode: 'IN-B', ...second }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false) + assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false) + } +}) + +test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => { + for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) { + const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/') + ? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B', + orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED') + } +}) + +test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => { + for (const initialStatus of ['PENDING', 'PROCESSING']) { + let completed = false + let version = initialStatus === 'PROCESSING' ? 1 : 0 + let uploads = 0 + const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 } + const context = contextFor((method, path, body) => { + if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1', + customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }]) + if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100', + overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8, + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] }) + if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2', + customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1', + outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] }) + if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] }) + if (method === 'POST' && path.endsWith('/pallets')) return ok('71') + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) } + if (path.includes('/complete')) { + assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1, + targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' }) + const replayed = completed; completed = true + return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed }) + } + if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } }) + return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN', + outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] }) + }) + context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + const resume = initialStatus === 'PROCESSING' + ? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null + const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }) + assert.equal(result?.restoredPackages, 1) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2) + if (initialStatus === 'PROCESSING') { + assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false) + assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false) + assert.equal(uploads, 0) + assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE')) + } + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + } +}) + +test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => { + const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null) + assert.equal(context.calls.length, 1) + assert.ok(context.assertions.some(row => !row.passed)) +}) + +test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => { + const context = contextFor((method, path) => { + if (path.includes('/inventory/')) return ok({ records: [], total: 0 }) + if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 }) + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } } + return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }) + }) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' } + const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2) + assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed)) + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE') + assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false) +}) + +test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => { + const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile') + ? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN' + ? { http: 404, body: { code: 'A0404' } } + : ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] })) + const previousFetch = globalThis.fetch + globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN' + ? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } }) + : new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } }) + try { + assert.equal(await verifyCustomerSignedDocuments(context, 'OWN', + [{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false) + } finally { globalThis.fetch = previousFetch } +}) + +test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => { + const context = contextFor((method, path, body, token) => { + assert.equal(path, '/api/auth/login') + assert.equal(token, '') + assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' }) + return ok({ accessToken: 'SECONDARY-TOKEN' }) + }) + context.options.customerName = 'PRIMARY' + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const primary = context.context.merchantQualificationFixture + const options = context.options + const create = async (secondary, flags) => { + assert.equal(flags.publishEnvironment, false) + assert.equal(secondary.marker, `${context.marker}-SIGNED-B`) + assert.notEqual(secondary.options, context.options) + assert.notEqual(secondary.context, context.context) + assert.notEqual(secondary.createdResources, context.createdResources) + assert.equal(secondary.options.customerName, '') + secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY', + password: 'SECONDARY-IN-MEMORY', marker: secondary.marker } + // Even unexpected producer fields cannot enter the report's explicit metadata whitelist. + secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' } + } + assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN') + assert.equal(context.options, options) + assert.equal(context.options.customerName, 'PRIMARY') + assert.equal(context.context.merchantQualificationFixture, primary) + assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN') + const report = JSON.stringify(context.createdResources) + assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/) + assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B') + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => { + const context = contextFor(() => { throw new Error('must not log in') }) + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} }) + assert.equal(token, null) + assert.equal(context.calls.length, 0) + assert.ok(context.assertions.some(row => !row.passed)) + assert.equal(context.context.phase5TailVerification, undefined) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7005eaab7..0f31e2a9f 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -1,4 +1,5 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' +import { run as createMerchant } from './merchant-qualification-fixture.mjs' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -66,6 +67,221 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { return { result, attempts } } +async function allPages(context, path, values, name) { + const rows = [] + for (let page = 1; page <= 50; page += 1) { + const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 })) + if (!success(current.response) || !Array.isArray(current.data?.records)) return null + rows.push(...current.data.records) + const total = Number(current.data.total) + if (Number.isInteger(total) && rows.length >= total) { + const complete = rows.length === total && rows.every(row => nonBlank(id(row.id))) + && new Set(rows.map(row => id(row.id))).size === rows.length + context.assert(MODULE, `${name}无分页遗漏或重复`, current.response, + complete, + `rows=${rows.length}, total=${total}`, OPTIONS) + return complete ? rows : null + } + if (current.data.records.length === 0) break + } + blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据') + return null +} + +export function inventoryQuantitySnapshot(rows) { + return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode, + totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages), + holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages), + pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id)) +} + +async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { + const rows = [] + for (const inboundCode of [...new Set(inboundCodes)]) { + const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, + { customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`) + if (!current) return null + // The server's text filter may match child codes; inventory evidence must match the exact ticket. + rows.push(...current.filter(row => row.inboundCode === inboundCode + && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + } + return inventoryQuantitySnapshot(rows) +} + +async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { + const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`, + { customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name) + if (!rows) return null + const exact = rows.filter(row => id(row.customerId) === customerId + && row.outboundOrderNo === outboundNo && row.businessType === businessType + && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) + context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, + `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) + return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode, + palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages), + beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) + +// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. +export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { + if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) + || !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0 + || pendingInboundCodes.includes(deliveredInboundCode)) { + blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源') + return false + } + const rows = [] + for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) { + const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`, + query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 })) + rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode)) + } + const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId)) + const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED' + && rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus)) + context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null, + validPartialCompletion, + `codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS) + if (!validPartialCompletion) return false + const current = await platform(context, 'GET', '未全签收时回查订单完成门禁', + query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId })) + const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED' + context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response, + blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + return sameOrder && blocked +} + +export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) { + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) { + blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号') + return null + } + const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId }) + const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath) + const order = before.data?.order + const sources = before.data?.returnSources + if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN' + || order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) { + blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response) + return null + } + const continuing = order.status === 'PROCESSING' + const previousAttempt = resume || owned.returnReceiptAttempt + if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId)) + || !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId)) + || (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo) + || (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) { + blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response) + return null + } + const source = sources[0] + const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存') + const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) + if (!stockBefore || !ledgerBefore) return null + const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}` + const palletCode = continuing ? previousAttempt.targetPalletNo + : `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) + let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : '' + if (!continuing) { + const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`, + { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` }) + targetPalletId = id(created.data) + if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null + } + const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`) + const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位') + const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId)) + context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response, + id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode + && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) + if (!location?.locationCode) return null + let processingVersion = Number(order.version) + let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' + if (!continuing) { + const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } + const started = await platform(context, 'POST', '开始处理同一派送退回异常', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) + if (!success(started.response) || started.data?.status !== 'PROCESSING') return null + processingVersion = Number(started.data.version) + const evidence = await context.requestMultipart('POST', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), + { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) }) + const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS) + if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null + evidenceFileId = id(uploaded.fileId) + } + const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) + ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') + if (!Number.isInteger(processingVersion) || processingVersion < 0) { + blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) + return null + } + owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode, + evidenceFileId, completeIdempotencyKey } + const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey, + handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId], + receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId), + receivedPackages: 1, targetPalletId, targetPalletNo: palletCode, + targetLocationId: id(location.id), targetLocationCode: location.locationCode }] } + const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId }) + const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody) + context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response, + completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1 + && /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS) + if (!success(completed.response)) { + const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存') + const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response, + sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS) + return null + } + const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存') + const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo) + const targetPositions = [] + for (const balance of stockAfter || []) { + const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置', + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode })) + if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode)) + } + context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null, + targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode + && id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id) + && id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId + && Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1, + `targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS) + const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) + context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, + stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 + && newRows?.length === 1 && newRows[0].deltaPackages === 1 + && newRows[0].afterPackages - newRows[0].beforePackages === 1, + `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) + const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody) + const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存') + const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response, + replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId) + && sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS) + const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath) + const delivery = await platform(context, 'GET', '回查退回实收后的原派送单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId })) + context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response, + final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo + && final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId) + && final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId) + && id(row.inventoryPositionId) === id(source.inventoryPositionId)) + && delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo, + trace(final.response), OPTIONS) + return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, + inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages), + targetPositionIds: targetPositions.map(row => id(row.id)) } +} + export async function saveReceiptAndComplete(context, delivery, customerId, index) { const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) @@ -86,6 +302,11 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) if (!success(uploadResponse) || !fileId) return null + if (context.createdResources.phase5OverseasFulfillment) { + const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] + receipts.push({ deliveryNo, fileId, + content: `${context.marker} receipt acceptance for ${deliveryNo}` }) + } const existing = (before.data?.categories || []).map((category) => ({ category: category.category, @@ -115,6 +336,112 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null } +async function receiptBinary(context, token, path) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + const contentType = response.headers.get('content-type') || '' + let body = null + if (/json/i.test(contentType)) { + try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ } + } + return { http: response.status, body, bytes, contentType, + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + } catch (error) { + return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) } + } +} + +const hiddenOrForbidden = response => [403, 404].includes(response?.http) + || (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response))) + +export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) { + // Own options, credentials and resource storage prevent the secondary fixture from changing the main customer. + const secondary = Object.create(context) + secondary.marker = `${context.marker}-SIGNED-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context } + secondary.createdResources = {} + await create(secondary, { publishEnvironment: false }) + const fixture = secondary.context?.merchantQualificationFixture + const safe = secondary.createdResources?.merchantQualificationFixture + const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName, + username: safe?.username, marker: safe?.marker } + const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker + && /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId) + && nonBlank(fixture?.username) && nonBlank(fixture?.password) + context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null, + ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS) + if (!ready) { + blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代') + return null + } + context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata + const login = await context.request('POST', '/api/auth/login', + { loginId: fixture.username, password: fixture.password }, '') + const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS) + if (!success(login) || !nonBlank(auth?.accessToken)) { + blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login) + return null + } + context.context.phase5TailVerification = { ...context.context.phase5TailVerification, + foreignMerchantToken: auth.accessToken } + return auth.accessToken +} + +export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) { + if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) { + blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话') + return false + } + let verified = true + if (foreignToken) { + const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id) + context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response, + distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS) + if (!distinct) return false + } + for (const receipt of receiptFiles) { + const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt` + const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base) + const bound = detail.data?.deliveryNo === receipt.deliveryNo + && detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId)) + context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS) + const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download` + const download = await receiptBinary(context, merchantToken, downloadPath) + const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType) + && new TextDecoder().decode(download.bytes) === receipt.content + context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS) + verified &&= Boolean(bound && exactBytes) + const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo) + if (otherReceipt) { + const wrongFile = await receiptBinary(context, merchantToken, + `${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`) + context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile, + hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS) + verified &&= hiddenOrForbidden(wrongFile) + } + if (foreignToken) { + const deniedDetail = await context.request('GET', base, undefined, foreignToken, {}) + context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail, + hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS) + const deniedFile = await receiptBinary(context, foreignToken, downloadPath) + context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile, + hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS) + verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile) + } + } + if (!foreignToken) { + blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝') + return false + } + return verified +} + export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) @@ -349,16 +676,40 @@ export async function run(context) { const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null + const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] + if (dispatchInboundCodes.length === 0) { + blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') + await disablePicker(context, picker, 5) + return + } + const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存') + // Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks. + const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水') try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } - if (!await pickAll(context, picker.token, dispatchNo)) return + if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return + const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存') + const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水') + const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id)) + const expectedPackages = Number(transferred.data.packageCount) + context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, + stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages + && expectedPackages > 0 && shippedRows?.length > 0 + && -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages + && shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages), + `before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS) + const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response, + ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS) + if (!success(ready.response) || ready.data?.canDepart !== true) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response, + context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, trace(departed.response), OPTIONS) if (!success(departed.response)) return @@ -368,6 +719,16 @@ export async function run(context) { departReplay.data?.replayed === true && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) + const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存') + const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') + context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, + sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.inventoryProof = { + inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), + afterPackages: quantityTotal(stockAfterDepart), expectedPackages, + shipmentLedgerIds: (shippedRows || []).map(row => row.id), + canDepart: ready.data?.canDepart === true, + } } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null @@ -453,6 +814,17 @@ export async function run(context) { context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) + context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId + const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态', + `/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`) + context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response, + customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED' + && id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId, + trace(customerCompletedOrder.response), OPTIONS) + const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken + || await createSignedDocumentIsolationCustomer(context) + await verifyCustomerSignedDocuments(context, merchantToken, + context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { @@ -531,5 +903,9 @@ export async function run(context) { exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) - context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' + const returnReceipt = await completeReturnedException(context, { customerId, warehouseId, + exceptionNo: returnExceptionNo, outboundNo }) + context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt + context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt + ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py new file mode 100644 index 000000000..0dfbd0a6d --- /dev/null +++ b/scripts/event-delivery-fault-proxy.py @@ -0,0 +1,296 @@ +"""Explicitly selected test-event ACK loss. Does not create or replay events. + +Run with an explicit consumer URL, event type and exactly one event selector. +EVENT_FAULT_CONTROL_TOKEN protects the separate loopback control listener. +Default PASS; authenticated POST /mode {"mode":"FAULT"} enables ACK loss. +The parent operator owns producer configuration, DEAD/retry and restoration. +""" + +import argparse +import hashlib +import hmac +import json +import os +import re +import signal +import threading +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MAX_BODY = 8 * 1024 * 1024 +EVENT_ID = re.compile(r"(?:[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}|[0-9A-HJKMNP-TV-Z]{26})\Z") +HASH = re.compile(r"sha256:[0-9a-fA-F]{64}\Z") +HOP_HEADERS = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"} + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +class State: + def __init__(self, event_type, event_id, loading_plan_id, log_path=None): + self.event_type = event_type + self.selector_id = event_id + self.plan_id = loading_plan_id + self.log_path = log_path + self.lock = threading.Lock() + self.mode = "PASS" + self.event_id = None + self.payload_hash = None + self.envelope_hash = None + self.attempt = 0 + self.records = [] + + def select(self, body): + try: + event = json.loads(body) + except (ValueError, UnicodeError): + return None, False, None, 0, self.mode + if not isinstance(event, dict): + return None, False, None, 0, self.mode + event_id, payload_hash = event.get("eventId"), event.get("payloadHash") + # Only valid bounded identifiers may enter evidence, never arbitrary payload strings. + if not isinstance(event_id, str) or not EVENT_ID.fullmatch(event_id): + return None, False, None, 0, self.mode + if not isinstance(payload_hash, str) or not HASH.fullmatch(payload_hash): + with self.lock: + if self.event_id == event_id: + self.attempt += 1 + return {"eventId": event_id, "payloadHash": self.payload_hash}, True, \ + "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + return None, False, None, 0, self.mode + identity = {"eventId": event_id, "payloadHash": payload_hash.lower()} + payload = event.get("payload") + qualifies = event.get("eventType") == self.event_type and ( + event_id == self.selector_id if self.selector_id else + isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id) + envelope_hash = hashlib.sha256(json.dumps(event, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + with self.lock: + # Reusing the pinned ID with another hash/content/type is rejected even if a + # changed selector field would otherwise make the request look unrelated. + if self.event_id == event_id and (self.payload_hash != payload_hash.lower() + or self.envelope_hash != envelope_hash): + self.attempt += 1 + return identity, True, "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + if not qualifies or (self.event_id is not None and self.event_id != event_id): + return identity, False, None, 0, self.mode + if self.event_id is None: + self.event_id, self.payload_hash, self.envelope_hash = event_id, payload_hash.lower(), envelope_hash + self.attempt += 1 + return identity, True, None, self.attempt, self.mode + + def record(self, identity, matched, attempt, outcome, error_code): + record = {"time": datetime.now(timezone.utc).isoformat(), + "eventId": identity.get("eventId") if identity else None, + "payloadHash": identity.get("payloadHash") if identity else None, + "outcome": outcome, "attempt": attempt, "matched": matched, "errorCode": error_code} + with self.lock: + # Store only target evidence; unrelated delivery payloads/identities are not retained. + if matched: + self.records.append(record) + if self.log_path: + with open(self.log_path, "a", encoding="utf-8") as log: + log.write(json.dumps(record, separators=(",", ":")) + "\n") + + def snapshot(self): + with self.lock: + return {"mode": self.mode, "eventId": self.event_id, "payloadHash": self.payload_hash, + "attempt": self.attempt, "records": list(self.records)} + + +class QuietHandler(BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + def reply(self, status, body, content_type="application/json", headers=None): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for name, value in (headers or {}).items(): + if name.lower() not in HOP_HEADERS | {"content-type"}: + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def safe_error(self, status, code): + self.reply(status, encoded({"errorCode": code})) + + def read_body(self): + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + length = -1 + if not 0 <= length <= MAX_BODY: + self.safe_error(413, "INVALID_BODY_LENGTH") + return None + body = self.rfile.read(length) + if len(body) != length: + self.safe_error(400, "INCOMPLETE_BODY") + return None + return body + + +def data_handler(state, upstream, timeout): + parts = urllib.parse.urlsplit(upstream) + endpoint = urllib.parse.urlunsplit(("", "", parts.path or "/", parts.query, "")) + + class DataHandler(QuietHandler): + def do_POST(self): + if self.path != endpoint: + self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") + return + body = self.read_body() + if body is None: + return + identity, matched, error_code, attempt, mode = state.select(body) + if error_code: + state.record(identity, matched, attempt, None, error_code) + self.safe_error(409, error_code) + return + # Forward the exact body and end-to-end headers. Never persist auth headers. + connection_headers = set(self.headers.get("Connection", "").lower().replace(" ", "").split(",")) + headers = {name: value for name, value in self.headers.items() + if name.lower() not in HOP_HEADERS | connection_headers} + request = urllib.request.Request(upstream, data=body, headers=headers, method="POST") + try: + # Redirects are forbidden: a redirect could expose a service identity token. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + consumer = opener.open(request, timeout=timeout) + except urllib.error.HTTPError as error: + consumer = error + with consumer: + status = consumer.code + result = consumer.read(MAX_BODY + 1) + result_headers = dict(consumer.headers.items()) + except (OSError, urllib.error.URLError): + state.record(identity, matched, attempt, None, "UPSTREAM_TRANSPORT_ERROR") + self.safe_error(502, "UPSTREAM_TRANSPORT_ERROR") + return + if len(result) > MAX_BODY: + state.record(identity, matched, attempt, None, "UPSTREAM_BODY_TOO_LARGE") + self.safe_error(502, "UPSTREAM_BODY_TOO_LARGE") + return + outcome = None + if matched: + try: + wrapper = json.loads(result) + ack = wrapper.get("data") + valid = status == 200 and str(wrapper.get("code")) == "200" and isinstance(ack, dict) \ + and ack.get("eventId") == identity["eventId"] \ + and str(ack.get("payloadHash", "")).lower() == identity["payloadHash"] \ + and ack.get("outcome") in {"APPLIED", "DUPLICATE"} + if valid: + outcome = ack["outcome"] + except (ValueError, UnicodeError, AttributeError): + valid = False + if not valid: + state.record(identity, matched, attempt, None, "UPSTREAM_ACK_INVALID") + self.safe_error(502, "UPSTREAM_ACK_INVALID") + return + if mode == "FAULT": + state.record(identity, matched, attempt, outcome, "ACK_LOSS_INJECTED") + self.safe_error(503, "ACK_LOSS_INJECTED") + return + state.record(identity, matched, attempt, outcome, None) + self.reply(status, result, result_headers.get("Content-Type", "application/json"), result_headers) + + return DataHandler + + +def control_handler(state, control_token): + class ControlHandler(QuietHandler): + def authorized(self): + received = self.headers.get("X-Event-Fault-Control-Token", "") + if not hmac.compare_digest(received.encode(), control_token.encode()): + self.safe_error(403, "CONTROL_FORBIDDEN") + return False + return True + + def do_GET(self): + if not self.authorized(): + return + if self.path != "/status": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + self.reply(200, encoded(state.snapshot())) + + def do_POST(self): + if not self.authorized(): + return + if self.path != "/mode": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + body = self.read_body() + if body is None: + return + try: + value = json.loads(body) + mode = value["mode"] + if mode not in {"PASS", "FAULT"}: + raise ValueError() + except (ValueError, KeyError, TypeError, UnicodeError): + self.safe_error(400, "INVALID_MODE") + return + with state.lock: + state.mode = mode + self.reply(200, encoded(state.snapshot())) + + return ControlHandler + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream", required=True) + parser.add_argument("--event-type", required=True) + selector = parser.add_mutually_exclusive_group(required=True) + selector.add_argument("--event-id") + selector.add_argument("--loading-plan-id") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--control-port", type=int, default=0) + parser.add_argument("--timeout", type=float, default=20) + parser.add_argument("--log") + args = parser.parse_args() + parts = urllib.parse.urlsplit(args.upstream) + token = os.environ.get("EVENT_FAULT_CONTROL_TOKEN", "") + if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password or parts.fragment: + parser.error("upstream must be an explicit HTTP consumer endpoint without credentials or fragment") + if len(token) < 24: + parser.error("EVENT_FAULT_CONTROL_TOKEN must have at least 24 characters") + if args.event_id and not EVENT_ID.fullmatch(args.event_id): + parser.error("event-id must be a UUID or ULID") + if args.loading_plan_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.loading_plan_id): + parser.error("loading-plan-id must be a positive decimal identifier") + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: + parser.error("invalid event type or timeout") + state = State(args.event_type, args.event_id, args.loading_plan_id, args.log) + data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) + control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) + for server in (data, control): + server.daemon_threads = True + stop = threading.Event() + for number in (signal.SIGINT, signal.SIGTERM): + signal.signal(number, lambda *_args: stop.set()) + threads = [threading.Thread(target=server.serve_forever, daemon=True) for server in (data, control)] + for thread in threads: + thread.start() + print(json.dumps({"ready": True, "dataPort": data.server_port, "controlPort": control.server_port}), flush=True) + stop.wait() + for server in (data, control): + server.shutdown() + server.server_close() + for thread in threads: + thread.join() + + +if __name__ == "__main__": + main() diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs new file mode 100644 index 000000000..ad2828bb3 --- /dev/null +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const script = fileURLToPath(new URL('./event-delivery-fault-proxy.py', import.meta.url)) +const targetId = 'e6259b1a-e112-4233-83e1-ae0071db3c52' +const otherId = '1b344fb7-d5d9-4324-b2d1-51bd0ab3af55' +const samePlanOtherId = '05dab8d1-10fd-4c9e-b242-54f4c6ff76a4' +const hash = `sha256:${'a'.repeat(64)}` +const secret = 'test-service-identity-secret-do-not-log' +const controlToken = 'test-control-secret-with-more-than-24-characters' +const event = (id = targetId, planId = '2100000000000000001') => ({ eventId: id, + eventType: 'LoadingCompleted', payloadHash: hash, + payload: { loadingPlanId: planId, syntheticPrivateField: secret } }) +const close = server => new Promise(resolve => server.close(resolve)) + +async function fixture(selector = ['--loading-plan-id', '2100000000000000001']) { + const calls = [] + const applied = new Set() + let invalidAck = false + const consumer = createServer(async (request, response) => { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const raw = Buffer.concat(chunks).toString() + const envelope = JSON.parse(raw) + calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId }) + const outcome = applied.has(envelope.eventId) ? 'DUPLICATE' : 'APPLIED' + applied.add(envelope.eventId) + const body = JSON.stringify({ code: '200', data: { eventId: invalidAck ? otherId : envelope.eventId, + payloadHash: envelope.payloadHash, outcome } }) + response.writeHead(200, { 'Content-Type': 'application/json', 'X-Consumer-Evidence': 'actual-upstream' }) + response.end(body) + }) + await new Promise(resolve => consumer.listen(0, '127.0.0.1', resolve)) + const directory = await mkdtemp(join(tmpdir(), 'event-fault-offline-')) + const log = join(directory, 'evidence.jsonl') + const child = spawn(process.env.PYTHON || 'python', [script, '--upstream', + `http://127.0.0.1:${consumer.address().port}/internal/events`, '--event-type', 'LoadingCompleted', + ...selector, '--log', log], { env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken }, + stdio: ['ignore', 'pipe', 'pipe'] }) + let output = ''; let errors = '' + child.stdout.on('data', chunk => { output += chunk }) + child.stderr.on('data', chunk => { errors += chunk }) + const ready = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(new Error('Proxy readiness timeout')) }, 8000) + const onData = () => { + const line = output.split('\n').find(item => item.trim()) + if (!line) return + try { const value = JSON.parse(line); clearTimeout(timer); child.stdout.off('data', onData); resolve(value) } + catch { clearTimeout(timer); reject(new Error('Invalid proxy readiness JSON')) } + } + child.stdout.on('data', onData) + child.once('error', error => { clearTimeout(timer); reject(error) }) + child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } }) + }) + const dataUrl = `http://127.0.0.1:${ready.dataPort}/internal/events` + const controlUrl = `http://127.0.0.1:${ready.controlPort}` + return { calls, applied, log, invalidAck: value => { invalidAck = value }, + send: async envelope => { + const sent = JSON.stringify(envelope) + const response = await fetch(dataUrl, { method: 'POST', body: sent, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` } }) + return { status: response.status, raw: await response.text(), headers: response.headers, sent } + }, + control: (path, body, token = controlToken) => fetch(`${controlUrl}${path}`, { + method: body ? 'POST' : 'GET', headers: { 'X-Event-Fault-Control-Token': token, + 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, + }), + output: () => `${output}\n${errors}`, + stop: async () => { + if (child.exitCode === null) { + const exited = new Promise(resolve => child.once('exit', resolve)) + child.kill(); await exited + } + await close(consumer) + await rm(directory, { recursive: true }) + }, + } +} + +test('ACK-loss proxy only targets one event and forwards committed upstream results before dropping ACK', async t => { + const proxy = await fixture() + t.after(() => proxy.stop()) + const unauthorized = await proxy.control('/mode', { mode: 'FAULT' }, 'wrong-control-token') + assert.equal(unauthorized.status, 403) + let status = await (await proxy.control('/status')).json() + assert.equal(status.mode, 'PASS') + assert.equal((await proxy.control('/mode', { mode: 'FAULT' })).status, 200) + + await t.test('real consumer is reached and durable APPLIED then DUPLICATE both lose ACK', async () => { + const first = await proxy.send(event()) + assert.equal(first.status, 503) + assert.equal(JSON.parse(first.raw).errorCode, 'ACK_LOSS_INJECTED') + assert.ok(proxy.applied.has(targetId)) + const duplicate = await proxy.send(event()) + assert.equal(duplicate.status, 503) + assert.equal(proxy.calls.length, 2) + assert.equal(proxy.calls[0].raw, first.sent) + assert.equal(proxy.calls[0].authorization, `Bearer ${secret}`) + status = await (await proxy.control('/status')).json() + assert.deepEqual(status.records.map(item => item.outcome), ['APPLIED', 'DUPLICATE']) + assert.deepEqual(status.records.map(item => item.attempt), [1, 2]) + }) + + await t.test('other plans, other types and another same-plan ID are unaffected', async () => { + for (const envelope of [event(otherId, '2100000000000000002'), + { ...event(otherId), eventType: 'CarrierScheduleNodeUpserted' }, event(samePlanOtherId)]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 200) + assert.equal(JSON.parse(result.raw).data.eventId, envelope.eventId) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + } + status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 2) + assert.equal(status.records.length, 2) + }) + + await t.test('pinned identity rejects hash, hidden payload and malformed-hash changes before upstream', async () => { + const before = proxy.calls.length + for (const envelope of [{ ...event(), payloadHash: `sha256:${'b'.repeat(64)}` }, + { ...event(), payload: { loadingPlanId: '2100000000000000002', changed: secret } }, + { ...event(), payloadHash: secret }]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 409) + assert.equal(JSON.parse(result.raw).errorCode, 'TARGET_EVENT_HASH_CONFLICT') + } + assert.equal(proxy.calls.length, before) + }) + + await t.test('PASS forwards matching duplicate ACK and original upstream response bytes and headers', async () => { + assert.equal((await proxy.control('/mode', { mode: 'PASS' })).status, 200) + const result = await proxy.send(event()) + assert.equal(result.status, 200) + assert.equal(result.raw, JSON.stringify({ code: '200', data: { eventId: targetId, payloadHash: hash, outcome: 'DUPLICATE' } })) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + status = await (await proxy.control('/status')).json() + assert.equal(status.records.at(-1).outcome, 'DUPLICATE') + assert.equal(status.records.at(-1).errorCode, null) + }) + + await t.test('incorrect upstream ACK is never replaced with plausible success evidence', async () => { + proxy.invalidAck(true) + const result = await proxy.send(event()) + assert.equal(result.status, 502) + assert.equal(JSON.parse(result.raw).errorCode, 'UPSTREAM_ACK_INVALID') + }) + + await t.test('state, JSONL evidence and subprocess output never expose private payload or identity secrets', async () => { + const records = (await readFile(proxy.log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + for (const record of records) { + assert.deepEqual(Object.keys(record).sort(), + ['time', 'eventId', 'payloadHash', 'outcome', 'attempt', 'matched', 'errorCode'].sort()) + assert.ok(record.eventId === targetId) + } + const evidence = `${JSON.stringify(await (await proxy.control('/status')).json())}\n${await readFile(proxy.log, 'utf8')}\n${proxy.output()}` + assert.ok(!evidence.includes(secret)) + assert.ok(!evidence.includes(controlToken)) + assert.ok(!evidence.includes('syntheticPrivateField')) + assert.ok(!evidence.includes('authorization')) + }) +}) + +test('explicit event-ID selector requires exact event type and forwards unrelated IDs unchanged', async t => { + const proxy = await fixture(['--event-id', targetId]) + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const other = await proxy.send(event(otherId)) + assert.equal(other.status, 200) + const wrongType = await proxy.send({ ...event(), eventType: 'OverseasDeliveryCompleted' }) + assert.equal(wrongType.status, 200) + const target = await proxy.send(event()) + assert.equal(target.status, 503) + const status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 1) + assert.equal(status.eventId, targetId) +})