test: strengthen customs and fulfillment acceptance boundaries

This commit is contained in:
2026-09-30 12:58:15 +08:00
parent 125ca34ba4
commit 80632ba409
13 changed files with 1616 additions and 40 deletions
+2
View File
@@ -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中。
+3 -1
View File
@@ -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 <test-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
```
@@ -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)
})
@@ -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'/)
@@ -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)
@@ -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
}
}
@@ -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) {
@@ -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/)
@@ -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'
@@ -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)
})
@@ -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'
}
+296
View File
@@ -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()
+181
View File
@@ -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)
})