test: strengthen customs and fulfillment acceptance boundaries
This commit is contained in:
@@ -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中。
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user