test: strengthen customs and fulfillment acceptance boundaries

This commit is contained in:
2026-09-30 12:58:15 +08:00
parent 125ca34ba4
commit 80632ba409
13 changed files with 1616 additions and 40 deletions
+3 -1
View File
@@ -167,7 +167,7 @@ export async function run(context) {
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式客户资格、订单、测量、同柜装载和最终完成 API 生成平台公共订舱下的买单与两客户退税混合柜。第二客户独立登录,凭据只在内存;验证确认/打回隔离、正式申报各类人工异常及剩余 OPEN 门禁、每客户完成文件、自动出仓和唯一船期任务。客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
| 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` |
@@ -224,6 +224,8 @@ node scripts/admin-test-env-acceptance.mjs --gateway <test-gateway> --modules ph
海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。
该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment
```
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs'
import {
CONTAINER_CUSTOMS_FIXTURE_KINDS,
@@ -92,3 +93,129 @@ test('database evidence list covers atomicity, history, dispatch and outbox with
'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table)
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i)
})
function runtimeContext() {
return { marker: 'OFFLINE-CUSTOMS', asserted: [],
assert(_module, name, _response, passed) { assert.ok(passed, name); this.asserted.push(name) },
addCoverageGap() {}, addStep() {},
}
}
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } })
function formalAdapter({ prematurelyClose = false, invalidOtherWrites = false } = {}) {
const container = { loadingPlanId: '1', containerBookingId: '2', containerNo: 'TESTU1234567',
status: 'DECLARING', version: 8, brokerSubmittedAt: '2026-09-30T10:00:00',
documents: [{ documentId: '10', source: { fileId: '11' }, receiptStatus: 'CONFIRMED' }], inbounds: [] }
let batch = null
return {
platformDetail: async () => response(container),
platformCreateDeclaration: async () => { batch = { id: '100', status: 'READY', version: 0, results: [] }; return response(batch) },
platformDeclarationBatches: async () => response([batch]),
platformSubmitDeclaration: async (_ctx, _fixture, _id, request) => {
assert.equal(request.expectedVersion, batch.version)
batch.status = 'SUBMITTED'; batch.version += 1; batch.customsEntryNo = request.customsEntryNo
return response(batch)
},
platformRecordClearance: async (_ctx, _fixture, _id, request) => {
assert.equal(request.expectedVersion, batch.version)
if (request.resultType === 'OTHER' && !request.resolution) {
if (invalidOtherWrites) batch.results.push({ id: 'invalid', resultStatus: 'OPEN' })
return response(null, 'A0400')
}
const result = { id: String(200 + batch.results.length), resultType: request.resultType,
resultStatus: request.resolved ? 'RESOLVED' : 'OPEN' }
batch.results.push(result)
batch.status = prematurelyClose && request.resolved ? 'CLOSED'
: batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED'
batch.version += 1
return response(result)
},
platformResolveClearance: async (_ctx, _fixture, resultId, request) => {
assert.equal(request.expectedVersion, batch.version)
const result = batch.results.find((item) => item.id === resultId)
result.resultStatus = 'RESOLVED'; batch.version += 1
batch.status = batch.results.some((item) => item.resultStatus === 'OPEN') ? 'RESULT_PENDING' : 'CLOSED'
return response(result)
},
platformClearanceResults: async () => response(batch.results),
}
}
test('formal declaration runs every manual exception, retains OPEN gates and keeps customs document facts', async () => {
const ctx = runtimeContext(); const fixture = {}
await verifyFormalDeclarationAndExceptions(ctx, formalAdapter(), fixture)
assert.equal(fixture.declarationBatchId, '100')
assert.equal(fixture.clearanceResultIds.length, 5)
for (const type of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) {
assert.ok(ctx.asserted.includes(`平台人工标记海关异常${type}`))
}
assert.ok(ctx.asserted.includes('海关人工处理保留客户资料回执确认原柜与报关四态'))
})
test('formal probe fails when a resolved result closes a batch with unresolved exceptions', async () => {
await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(),
formalAdapter({ prematurelyClose: true }), {}), /不能提前关闭申报批次/)
})
test('OTHER missing remark probe rejects hidden writes even when API returns expected error', async () => {
await assert.rejects(verifyFormalDeclarationAndExceptions(runtimeContext(),
formalAdapter({ invalidOtherWrites: true }), {}), /必须填写备注且批次结果零漂移/)
})
function mixedAdapter({ contaminateForeignConfirmation = false } = {}) {
const docs = [{ documentId: 'A', receipt: { version: 1, fileId: 'RA' }, receiptStatus: 'PENDING' },
{ documentId: 'B', receipt: null, receiptStatus: null }]
const container = { version: 2, status: 'DECLARING', documents: docs, brokerSubmittedAt: null }
const confirmations = new Set()
return {
platformDetail: async () => response(container),
platformUploadReceipt: async (_ctx, _fixture, request) => {
const doc = docs.find((item) => item.documentId === request.documentId)
assert.equal(request.expectedVersion, doc.receipt?.version || 0)
doc.receipt = { version: (doc.receipt?.version || 0) + 1, fileId: `R${doc.documentId}` }
doc.receiptStatus = 'PENDING'
return response(doc)
},
merchantConfirmReceipt: async (_ctx, _fixture, customer, request, commandKey) => {
if (customer.documentId !== request.documentId) {
if (contaminateForeignConfirmation) docs[0].receiptStatus = 'PENDING'
return response(null, 'A0404')
}
const doc = docs.find((item) => item.documentId === request.documentId)
assert.equal(request.expectedVersion, doc.receipt.version)
if (!confirmations.has(commandKey)) {
doc.receiptStatus = request.confirm ? 'CONFIRMED' : 'REJECTED'
confirmations.add(commandKey)
}
return response(container)
},
platformSubmitToBroker: async () => response(null, 'A0409'),
platformCreateDeclaration: async () => response(null, 'A0409'),
}
}
const mixedFixture = () => ({ customers: [{ documentId: 'A', customerId: '1' },
{ documentId: 'B', customerId: '2' }] })
test('multi-customer receipt flow rejects, replaces and confirms only the affected customer', async () => {
const ctx = runtimeContext()
const result = await verifyMixedReceiptIsolation(ctx, mixedAdapter(), mixedFixture(),
response({ receipt: { version: 1 } }))
assert.ok(result.body.data.documents.every((item) => item.receiptStatus === 'CONFIRMED'))
assert.ok(ctx.asserted.includes('未全部客户确认禁止发送报关行执行'))
assert.ok(ctx.asserted.includes('未全部确认禁止创建正式申报批次'))
})
test('cross-customer receipt denial also requires prior confirmation remain unchanged', async () => {
await assert.rejects(verifyMixedReceiptIsolation(runtimeContext(),
mixedAdapter({ contaminateForeignConfirmation: true }), mixedFixture(),
response({ receipt: { version: 1 } })), /第二客户不能确认其他客户回执且原确认保持/)
})
test('second customer credentials stay in adapter memory and are not exported with report resources', () => {
assert.match(fixtureSource, /publishEnvironment: false/)
assert.match(fixtureSource, /customerTokens\.get/)
assert.match(fixtureSource, /customers\.length < 2/)
const customerData = fixtureSource.slice(fixtureSource.indexOf('const customers ='), fixtureSource.indexOf('const buyInboundIds ='))
assert.doesNotMatch(customerData, /(?:sessionToken|accessToken|customerToken|password)/)
assert.doesNotMatch(fixtureSource, /\b(?:INSERT\s+INTO|DELETE\s+FROM|UPDATE\s+(?:fl_|ll_|wh_|ord_))/i)
})
@@ -1,11 +1,37 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { qualificationCreditCode } from './merchant-qualification-fixture.mjs'
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
test('credit codes distinguish repeated CUSTOMER-B and SIGNED-B fixtures using the complete marker', () => {
const customerId = '2100000000000000001'
const values = []
for (const ending of ['CUSTOMER-B', 'SIGNED-B']) {
const firstMarker = `ADMIN-API-20260930121500000-${ending}`
const secondMarker = `ADMIN-API-20260930121600000-${ending}`
const first = qualificationCreditCode(firstMarker, customerId)
const second = qualificationCreditCode(secondMarker, customerId)
assert.notEqual(first, second, `时间不同但后缀同为 ${ending} 必须生成不同代码`)
assert.equal(qualificationCreditCode(firstMarker, customerId), first)
for (const value of [first, second]) assert.match(value, /^91440300[0-9A-F]{10}$/)
values.push(first, second)
}
assert.equal(new Set(values).size, values.length)
assert.match(fixture, /unifiedSocialCreditCode = qualificationCreditCode\(context\.marker, tenantId\)/)
})
test('a new real customer under the same marker receives a distinct stable code', () => {
const marker = 'ADMIN-API-20260930121500000-CUSTOMER-B'
assert.notEqual(qualificationCreditCode(marker, '2100000000000000001'),
qualificationCreditCode(marker, '2100000000000000002'))
assert.throws(() => qualificationCreditCode('', '2100000000000000001'), /完整批次标识/)
assert.throws(() => qualificationCreditCode(marker, ''), /真实客户编号/)
})
test('默认全量验收先准备已审核商户再运行订单链路', () => {
const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || ''
assert.match(modules, /^'merchant-qualification-fixture', 'customers'/)
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto'
const MODULE = 'merchant-qualification-fixture'
const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'])
const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from(
@@ -9,6 +11,16 @@ function id(value) {
return value === undefined || value === null ? '' : String(value)
}
export function qualificationCreditCode(marker, tenantId) {
const customerId = id(tenantId)
if (!String(marker || '').trim() || !customerId) {
throw new Error('测试资质代码必须使用完整批次标识和本轮真实客户编号')
}
const suffix = createHash('sha256').update(JSON.stringify([String(marker), customerId]))
.digest('hex').slice(0, 10).toUpperCase()
return `91440300${suffix}`
}
async function waitForDimensionInitialization(context, tenantId) {
let response = null
let status = 'PENDING'
@@ -62,7 +74,7 @@ export async function run(context, { publishEnvironment = true } = {}) {
const packageForm = new FormData()
packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' }))
packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' }))
const unifiedSocialCreditCode = `91440300${suffix}`
const unifiedSocialCreditCode = qualificationCreditCode(context.marker, tenantId)
const packageResponse = await context.requestWithBody('POST',
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`,
packageForm)
@@ -50,7 +50,8 @@ async function loginMerchant(context) {
return token
}
function routes(context, merchantToken) {
function routes(context, merchantToken, customerTokens = new Map()) {
const tokenFor = (customer) => customerTokens.get(id(customer.customerId)) || merchantToken
const binary = (method, path, token = context.token) => raw(context, method, path, undefined, token)
const multipart = (path, fields, file, token, idempotencyKey) => {
const form = new FormData()
@@ -64,27 +65,27 @@ function routes(context, merchantToken) {
return {
version: 1,
platformDetail: (ctx, value) => ctx.request('GET', `${PLATFORM}/${encodeURIComponent(id(value.loadingPlanId))}`),
merchantDetail: (ctx, fixture, customer, token = merchantToken) => ctx.request('GET',
merchantDetail: (ctx, fixture, customer, token = tokenFor(customer)) => ctx.request('GET',
`${MERCHANT}/orders/${encodeURIComponent(id(customer.orderId))}`, undefined, token),
merchantPlatformDetail: (ctx, fixture) => ctx.request('GET',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}`, undefined, merchantToken),
merchantUploadDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart(
`${MERCHANT}/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/documents/upload?${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`,
{}, file, customer.customerToken || merchantToken, idempotencyKey),
{}, file, tokenFor(customer), idempotencyKey),
merchantReplaceDocument: (ctx, fixture, customer, file, idempotencyKey) => multipart(
`${MERCHANT}/documents/${encodeURIComponent(id(file.documentId))}/upload?expectedVersion=${Number(file.expectedVersion)}&${customer.inboundIds.map((value) => `inboundIds=${encodeURIComponent(id(value))}`).join('&')}`,
{}, file, customer.customerToken || merchantToken, idempotencyKey),
{}, file, tokenFor(customer), idempotencyKey),
merchantUpdateScope: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('PUT',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/inbounds`, {
inboundIds: request.inboundIds.map(id), expectedVersion: Number(request.expectedVersion),
}, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
}, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }),
merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`,
undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
undefined, tokenFor(customer), { 'Idempotency-Key': idempotencyKey }),
merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`,
{ expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' },
customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
tokenFor(customer), { 'Idempotency-Key': idempotencyKey }),
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
@@ -97,6 +98,22 @@ function routes(context, merchantToken) {
platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformDeclarationBatches: (ctx, fixture) => ctx.request('GET',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`),
platformCreateDeclaration: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/declaration-batches`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformSubmitDeclaration: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/submit`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformClearanceResults: (ctx, fixture, batchId) => ctx.request('GET',
`${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`),
platformRecordClearance: (ctx, fixture, batchId, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/declaration-batches/${encodeURIComponent(id(batchId))}/results`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformResolveClearance: (ctx, fixture, resultId, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/clearance-results/${encodeURIComponent(id(resultId))}/resolve`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
@@ -109,10 +126,10 @@ function routes(context, merchantToken) {
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`),
merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`,
customer.customerToken || merchantToken),
tokenFor(customer)),
merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`,
customer.customerToken || merchantToken),
tokenFor(customer)),
buyDeclarationXlsx: async (ctx, request) => {
const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', {
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX',
@@ -150,6 +167,98 @@ function routes(context, merchantToken) {
}
}
async function addSecondRefundCustomer(context) {
const source = context.createdResources.phase3Loading
const secondary = Object.create(context)
secondary.marker = `${context.marker}-CUSTOMER-B`
secondary.options = { ...context.options, customerName: '' }
secondary.context = { ...context.context, orderProfile: {
...(context.context.orderProfile || {}), taxMode: 'TAX_REFUND', fulfillmentType: 'STANDARD',
}, orderFulfillmentType: 'STANDARD' }
secondary.createdResources = {}
const qualification = await import('./merchant-qualification-fixture.mjs')
await qualification.run(secondary, { publishEnvironment: false })
const account = secondary.context.merchantQualificationFixture
// orders.mjs captures its credentials at import time. A distinct module instance captures
// this customer's credentials without changing the primary customer's cached producer.
const saved = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD'].map((name) =>
[name, Object.hasOwn(process.env, name), process.env[name]])
let orders
try {
process.env.MERCHANT_TEST_USERNAME = account.username
process.env.MERCHANT_TEST_PASSWORD = account.password
orders = await import(`./orders.mjs?customsCustomer=${encodeURIComponent(account.tenantId)}`)
} finally {
for (const [name, present, value] of saved) {
if (present) process.env[name] = value
else delete process.env[name]
}
}
await secondary.executeModule(`${MODULE}-customer-b-orders`, () => orders.run(secondary))
const measurement = await import('./inbound-measurement.mjs')
await secondary.executeModule(`${MODULE}-customer-b-measurement`, () => measurement.run(secondary))
const inbound = secondary.createdResources.inboundMeasurement
if (!inbound?.inboundId || id(inbound.tenantId) !== id(account.tenantId)
|| id(inbound.tenantId) === id(source.tenantId)) throw new Error('第二退税客户未生成独立入库单')
const login = await secondary.request('POST', '/api/auth/login', {
loginId: account.username, password: account.password,
}, '')
if (!success(login) || !login.body?.data?.accessToken) throw new Error('第二退税客户登录失败')
const smart = '/api/platform/v1/first-leg/smart-loading'
const candidatesResponse = await context.request('GET', `${smart}/candidates?${new URLSearchParams({
bookingId: id(source.containerBookingId), warehouseId: id(source.warehouseId),
})}`)
const candidates = context.apiSuccess(MODULE, '第二退税客户读取原柜正式可装候选', candidatesResponse, '', OPTIONS)
const candidate = (Array.isArray(candidates) ? candidates : candidates?.records || []).find((item) =>
id(item.inboundId) === id(inbound.inboundId) && id(item.accountId) === id(account.tenantId)
&& item.inboundDetailId && Number(item.availablePackageCount) > 0)
if (!candidate) throw new Error('第二退税客户在原柜没有正式可装候选,禁止借历史票')
const planResponse = await context.request('GET', `${smart}/plans/${id(source.loadingPlanId)}`)
const plan = context.apiSuccess(MODULE, '加入第二客户前回查原柜计划', planResponse, '', OPTIONS)
const assignedResponse = await context.request('POST', `${smart}/plans/${id(source.loadingPlanId)}/assignments`, {
inboundId: id(inbound.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(plan.version), operationId: key(context, 'customer-b-assign'),
})
const assigned = context.apiSuccess(MODULE, '第二退税客户正式分配到同柜', assignedResponse, '', OPTIONS)
const item = assigned?.items?.find((value) => id(value.inboundId) === id(inbound.inboundId))
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${id(source.canonicalPdaTaskId)}`
const taskResponse = await context.request('GET', `${taskPath}?warehouseId=${id(source.warehouseId)}`)
const task = context.apiSuccess(MODULE, '同柜新增第二客户后回查原PDA任务', taskResponse, '', OPTIONS)
const taskItem = task?.items?.find((value) => id(value.loadingPlanItemId) === id(item?.id))
const inventoryResponse = await context.request('GET', `${taskPath}/inventory-snapshot?warehouseId=${id(source.warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '第二客户刷新正式拣货库存快照', inventoryResponse, '', OPTIONS)
if (!taskItem || !inventory?.snapshotToken) throw new Error('第二客户缺少正式PDA明细或库存快照')
const pickedResponse = await context.request('POST', `${taskPath}/batch`, {
warehouseId: id(source.warehouseId), loadingPlanId: id(source.loadingPlanId),
containerBookingId: id(source.containerBookingId), expectedVersion: Number(task.version),
inventorySnapshotToken: inventory.snapshotToken,
items: [{ itemId: id(taskItem.id), action: 'PICK', pickedPackageCount: 1, version: Number(taskItem.version) }],
}, context.pdaToken || context.token, { 'Idempotency-Key': key(context, 'customer-b-pick') })
const picked = context.apiSuccess(MODULE, '同柜第二客户完成正式PDA拣货', pickedResponse, '', OPTIONS)
context.assert(MODULE, '第二客户加入后原柜原任务保持且恢复待完成', pickedResponse,
id(picked?.id) === id(source.canonicalPdaTaskId) && picked?.workflowStatus === 'AWAITING_REVIEW'
&& id(picked?.loadingPlanId) === id(source.loadingPlanId)
&& id(picked?.containerBookingId) === id(source.containerBookingId),
`workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS)
source.pdaTaskVersion = Number(picked?.version)
const { stableLoadedCartonMarks } = await import('./phase3-loading.mjs')
source.cartonMarks = stableLoadedCartonMarks(picked?.items)
return { customerId: id(account.tenantId), sessionToken: login.body.data.accessToken }
}
export async function withPlatformBookingScope(context, runLoading) {
const name = 'phase3LoadingBookingScope'
const present = Object.hasOwn(context.context, name)
const previous = context.context[name]
context.context[name] = 'PLATFORM'
try {
return await runLoading()
} finally {
if (present) context.context[name] = previous
else delete context.context[name]
}
}
export async function prepare(context) {
const outerMarker = context.marker
const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
@@ -165,6 +274,7 @@ export async function prepare(context) {
const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, {
primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND',
})
try {
const executionMarker = `${outerMarker}-CUSTOMS-MIXED`
context.marker = executionMarker
if (context.createdResources?.orchestrationFixtures?.phase3Loading) {
@@ -174,7 +284,9 @@ export async function prepare(context) {
context.createdResources.orchestrationFixtures.phase3LoadingIncremental.marker = executionMarker
}
const loading = await import('./phase3-loading.mjs')
await context.executeModule(`${MODULE}-loading`, () => loading.run(context))
await withPlatformBookingScope(context, () =>
context.executeModule(`${MODULE}-loading`, () => loading.run(context)))
const secondCustomer = await addSecondRefundCustomer(context)
const finalize = await import('./phase3-loading-finalize.mjs')
await context.executeModule(`${MODULE}-finalize`, () => finalize.run(context))
@@ -202,10 +314,11 @@ export async function prepare(context) {
throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`)
}
const merchantToken = await loginMerchant(context)
const customerId = id(refund[0].tenantId)
const customer = {
customerId, orderId: id(refund[0].orderId),
inboundIds: refund.map((item) => id(item.inboundId)),
const customers = [...new Set(refund.map((item) => id(item.tenantId)))].map((customerId) => {
const own = refund.filter((item) => id(item.tenantId) === customerId)
return {
customerId, orderId: id(own[0].orderId),
inboundIds: own.map((item) => id(item.inboundId)),
document: {
fileName: `${context.marker}-tax-refund.customblob`, contentType: 'application/octet-stream',
content: Uint8Array.from([0x54, 0x41, 0x58]),
@@ -214,8 +327,9 @@ export async function prepare(context) {
fileName: `${context.marker}-tax-refund-v2.customblob`, contentType: 'application/octet-stream',
content: Uint8Array.from([0x54, 0x41, 0x58, 0x32]),
},
updatedInboundIds: refund.map((item) => id(item.inboundId)),
}
updatedInboundIds: own.map((item) => id(item.inboundId)),
} })
if (customers.length < 2) throw new Error('同柜未形成两个实际退税客户')
const buyInboundIds = new Set(buy.map((item) => id(item.inboundId)))
const planBuyRows = planItems.filter((item) => buyInboundIds.has(id(item.inboundId))).length
const expectedBuyRows = planBuyRows > 0 ? planBuyRows : buy.length
@@ -230,14 +344,21 @@ export async function prepare(context) {
inboundId: id(item.inboundId), orderId: id(item.orderId), customerId: id(item.tenantId),
inboundCode: item.inboundCode, taxMode: item.taxMode,
})),
customers: [customer], expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status,
customers, expectedBuyRows, buyInboundIds: [...buyInboundIds], customsStatus: customs.status,
}],
}
context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken)
context.context.phase3ContainerCustomsRoutes = routes(context, merchantToken, new Map([[secondCustomer.customerId, secondCustomer.sessionToken]]))
return () => {
delete context.context.phase3ContainerCustomsRoutes
context.marker = outerMarker
restore()
restoreIsolatedResources()
}
} catch (error) {
delete context.context.phase3ContainerCustomsRoutes
context.marker = outerMarker
restore()
restoreIsolatedResources()
throw error
}
}
@@ -33,6 +33,11 @@ function resolvePreparation(context) {
if (!validateRouteContract(routes)) {
blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求')
}
if (requestedKinds.includes('MIXED') && ['platformDeclarationBatches', 'platformCreateDeclaration',
'platformSubmitDeclaration', 'platformClearanceResults', 'platformRecordClearance',
'platformResolveClearance'].some((name) => typeof routes[name] !== 'function')) {
blocker(context, '正式申报与人工海关结果HTTP契约', '缺少已核对Controller的正式申报或海关结果适配器')
}
return { fixtures, routes, requestedKinds }
}
@@ -260,6 +265,168 @@ async function runRefund(context, routes, fixture) {
}
export function customsDocumentFacts(container) {
return { loadingPlanId: id(container?.loadingPlanId), containerBookingId: id(container?.containerBookingId),
containerNo: container?.containerNo, status: container?.status,
documents: container?.documents || [], inbounds: container?.inbounds || [],
brokerSubmittedAt: container?.brokerSubmittedAt, brokerSubmission: container?.brokerSubmission }
}
export async function verifyMixedReceiptIsolation(context, routes, fixture, firstReceipt) {
const [first, other] = fixture.customers
const receiptVersion = Number(firstReceipt.body?.data?.receipt?.version)
const firstConfirmed = await routes.merchantConfirmReceipt(context, fixture, first, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
const firstReplay = await routes.merchantConfirmReceipt(context, fixture, first, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认回执后仍未完成整柜', firstReplay,
success(firstConfirmed) && success(firstReplay) && firstReplay.body?.data?.status === 'DECLARING',
evidence(firstReplay), OPTIONS)
const beforeOther = await platformDetail(context, routes, fixture)
const confirmedFirst = beforeOther.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)
for (const customer of fixture.customers.slice(1)) {
const uploaded = await routes.platformUploadReceipt(context, fixture, {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-${customer.customerId}-receipt.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x32]),
}, key(context, `receipt-${customer.customerId}`))
context.assert(MODULE, '第二客户报关回执独立上传', uploaded, success(uploaded), evidence(uploaded), OPTIONS)
}
const foreignConfirm = await routes.merchantConfirmReceipt(context, fixture, other, {
documentId: first.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'foreign-receipt-confirm'))
const foreignBefore = await platformDetail(context, routes, fixture)
context.assert(MODULE, '第二客户不能确认其他客户回执且原确认保持', foreignConfirm,
foreignConfirm.http === 200 && ['A0403', 'A0404'].includes(code(foreignConfirm))
&& zeroDrift(confirmedFirst, foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(foreignConfirm), OPTIONS)
const otherDocument = foreignBefore.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId)
const rejected = await routes.merchantConfirmReceipt(context, fixture, other, {
documentId: other.documentId, expectedVersion: Number(otherDocument?.receipt?.version),
confirm: false, remark: `${context.marker}-回执资料需修改`,
}, key(context, 'mixed-other-reject'))
const afterReject = await platformDetail(context, routes, fixture)
const rejectedDocument = afterReject.body?.data?.documents?.find((item) => id(item.documentId) === other.documentId)
context.assert(MODULE, '第二客户打回不撤销首个客户整份确认', rejected,
success(rejected) && rejectedDocument?.receiptStatus === 'REJECTED'
&& zeroDrift(confirmedFirst, afterReject.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(rejected), OPTIONS)
const earlyExecution = await routes.platformSubmitToBroker(context, fixture, {
expectedVersion: Number(afterReject.body?.data?.version), confirm: true, remark: context.marker,
}, key(context, 'mixed-unconfirmed-execution'))
await assertZeroDrift(context, '未全部客户确认禁止发送报关行执行', routes, fixture, afterReject, earlyExecution)
const earlyDeclaration = await routes.platformCreateDeclaration(context, fixture, {
expectedVersion: Number(afterReject.body?.data?.version), remark: context.marker,
}, key(context, 'mixed-unconfirmed-declaration'))
await assertZeroDrift(context, '未全部确认禁止创建正式申报批次', routes, fixture, afterReject, earlyDeclaration)
const replacement = await routes.platformUploadReceipt(context, fixture, {
documentId: other.documentId, expectedVersion: Number(rejectedDocument?.receipt?.version),
fileName: `${context.marker}-${other.customerId}-receipt-v2.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x52, 0x33]),
}, key(context, 'mixed-other-receipt-replacement'))
const afterReplacement = await platformDetail(context, routes, fixture)
context.assert(MODULE, '替换打回客户回执只重置该客户待确认且保留他人确认', replacement,
success(replacement) && replacement.body?.data?.receiptStatus === 'PENDING'
&& zeroDrift(confirmedFirst, afterReplacement.body?.data?.documents?.find((item) => id(item.documentId) === first.documentId)),
evidence(replacement), OPTIONS)
for (const customer of fixture.customers.slice(1)) {
const current = await platformDetail(context, routes, fixture)
const document = current.body?.data?.documents?.find((item) => id(item.documentId) === customer.documentId)
const confirmation = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: Number(document?.receipt?.version),
confirm: true, remark: context.marker,
}, key(context, `mixed-other-confirm-${customer.customerId}`))
context.assert(MODULE, '剩余客户确认自己当前整份回执', confirmation, success(confirmation), evidence(confirmation), OPTIONS)
}
const confirmed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '同柜全部客户确认齐全仍等待平台发送执行', confirmed,
success(confirmed) && confirmed.body?.data?.documents?.every((item) => item.receiptStatus === 'CONFIRMED')
&& confirmed.body?.data?.brokerSubmittedAt == null && confirmed.body?.data?.status === 'DECLARING',
evidence(confirmed), OPTIONS)
return confirmed
}
export async function verifyFormalDeclarationAndExceptions(context, routes, fixture) {
const before = await platformDetail(context, routes, fixture)
const baseline = customsDocumentFacts(before.body?.data)
const created = await routes.platformCreateDeclaration(context, fixture, {
expectedVersion: Number(before.body?.data?.version), batchNo: `${context.marker}-DECL`.slice(-64), remark: context.marker,
}, key(context, 'formal-create'))
context.assert(MODULE, '平台人工创建正式申报批次', created,
success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS)
if (!success(created)) blocker(context, '正式申报批次', '创建正式申报批次失败,保留资料并停止结果写入')
const batchId = id(created.body.data.id)
const batches = () => routes.platformDeclarationBatches(context, fixture)
const batch = async () => {
const response = await batches()
if (!success(response)) blocker(context, '正式申报批次回查', evidence(response))
const value = response.body.data.find((item) => id(item.id) === batchId)
if (!value) blocker(context, '正式申报批次回查', '本轮批次不在当前柜列表')
return value
}
const submitted = await routes.platformSubmitDeclaration(context, fixture, batchId, {
expectedVersion: Number(created.body.data.version), customsEntryNo: `${context.marker}-ENTRY`.slice(-64), remark: context.marker,
}, key(context, 'formal-submit'))
context.assert(MODULE, '人工回填报关单号提交正式申报', submitted,
success(submitted) && submitted.body?.data?.status === 'SUBMITTED'
&& Boolean(submitted.body?.data?.customsEntryNo), evidence(submitted), OPTIONS)
if (!success(submitted)) blocker(context, '正式申报提交', '正式提交失败,禁止继续人工录入海关结果')
const local = new Date()
const occurredAt = `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}T${String(local.getHours()).padStart(2, '0')}:${String(local.getMinutes()).padStart(2, '0')}:${String(local.getSeconds()).padStart(2, '0')}`
const missingRemarkBatch = await batch()
const invalidOther = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(missingRemarkBatch.version), resultType: 'OTHER', occurredAt, resolution: '', resolved: false,
}, key(context, 'other-missing-remark'))
const afterInvalid = await batch()
context.assert(MODULE, '海关其他异常必须填写备注且批次结果零漂移', invalidOther,
invalidOther.http === 200 && code(invalidOther) === 'A0400' && zeroDrift(missingRemarkBatch, afterInvalid),
evidence(invalidOther), OPTIONS)
const opened = []
for (const resultType of ['INSPECTION', 'SUPPLEMENT_REQUIRED', 'DETAINED', 'OTHER']) {
const current = await batch()
const recorded = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(current.version), resultType, occurredAt,
resolution: `${context.marker}-${resultType}-人工记录`, resolved: false,
}, key(context, `clearance-${resultType}`))
context.assert(MODULE, `平台人工标记海关异常${resultType}`, recorded,
success(recorded) && recorded.body?.data?.resultType === resultType && recorded.body?.data?.resultStatus === 'OPEN',
evidence(recorded), OPTIONS)
if (!success(recorded)) blocker(context, '人工海关异常', `${resultType}保存失败`)
opened.push(id(recorded.body.data.id))
}
const pending = await batch()
const released = await routes.platformRecordClearance(context, fixture, batchId, {
expectedVersion: Number(pending.version), resultType: 'RELEASED', occurredAt,
resolution: `${context.marker}-人工录入放行短信`, resolved: true,
}, key(context, 'clearance-released-with-open'))
const stillPending = await batch()
context.assert(MODULE, '已有未解除异常时新增已处理结果不能提前关闭申报批次', released,
success(released) && stillPending.status === 'RESULT_PENDING'
&& stillPending.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length,
evidence(released), OPTIONS)
for (const [index, resultId] of opened.entries()) {
const current = await batch()
const resolved = await routes.platformResolveClearance(context, fixture, resultId, {
expectedVersion: Number(current.version), resolution: `${context.marker}-平台人工确认解除`,
}, key(context, `clearance-resolve-${resultId}`))
const after = await batch()
context.assert(MODULE, `人工解除海关异常并保留剩余门禁${index + 1}`, resolved,
success(resolved) && resolved.body?.data?.resultStatus === 'RESOLVED'
&& after.status === (index === opened.length - 1 ? 'CLOSED' : 'RESULT_PENDING')
&& after.results.filter((item) => item.resultStatus === 'OPEN').length === opened.length - index - 1,
evidence(resolved), OPTIONS)
}
const results = await routes.platformClearanceResults(context, fixture, batchId)
const after = await platformDetail(context, routes, fixture)
context.assert(MODULE, '海关人工处理保留客户资料回执确认原柜与报关四态', after,
success(results) && results.body?.data?.length === 5 && results.body.data.every((item) => item.resultStatus === 'RESOLVED')
&& success(after) && zeroDrift(baseline, customsDocumentFacts(after.body?.data)), evidence(after), OPTIONS)
fixture.declarationBatchId = batchId
fixture.clearanceResultIds = [...opened, id(released.body?.data?.id)]
}
async function runMixedConcurrency(context, routes, fixture) {
const initial = await platformDetail(context, routes, fixture)
const initialData = initial.body?.data
@@ -278,7 +445,7 @@ async function runMixedConcurrency(context, routes, fixture) {
if (!customer) blocker(context, '混合柜客户fixture', '缺少本轮退税客户与正式登录凭据')
const merchant = await routes.merchantDetail(context, fixture, customer, customer.customerToken)
context.assert(MODULE, 'Client只见本客户TAX票且完全隐藏BUY票', merchant,
success(merchant) && merchant.body?.data?.inbounds?.length === refund.length
success(merchant) && merchant.body?.data?.inbounds?.length === customer.inboundIds.length
&& merchant.body.data.inbounds.every((item) => item.taxMode === 'TAX_REFUND')
&& !merchant.body.data.inbounds.some((item) => fixture.buyInboundIds.includes(id(item.inboundId))),
evidence(merchant), OPTIONS)
@@ -303,7 +470,7 @@ async function runMixedConcurrency(context, routes, fixture) {
const uploadKey = key(context, 'mixed-tax-upload')
const uploaded = await routes.merchantUploadDocument(context, fixture, customer, customer.document, uploadKey)
context.assert(MODULE, 'Client上传资料只覆盖本柜TAX票', uploaded,
success(uploaded) && uploaded.body?.data?.inboundIds?.length === refund.length
success(uploaded) && uploaded.body?.data?.inboundIds?.length === customer.inboundIds.length
&& uploaded.body.data.inboundIds.every((value) => customer.inboundIds.includes(id(value)))
&& !uploaded.body.data.inboundIds.some((value) => fixture.buyInboundIds.includes(id(value))),
evidence(uploaded), OPTIONS)
@@ -314,6 +481,21 @@ async function runMixedConcurrency(context, routes, fixture) {
customer.documentId = id(uploaded.body?.data?.documentId)
customer.documentVersion = Number(uploaded.body?.data?.source?.version)
if (fixture.customers.length < 2) blocker(context, '同柜多退税客户', '必须由本轮正式装柜形成两个独立退税客户')
for (const other of fixture.customers.slice(1)) {
const isolated = await routes.merchantDetail(context, fixture, other)
context.assert(MODULE, '第二客户只见自身退税入仓票', isolated,
success(isolated) && isolated.body?.data?.inbounds?.length === other.inboundIds.length
&& isolated.body.data.inbounds.every((item) => other.inboundIds.includes(id(item.inboundId))),
evidence(isolated), OPTIONS)
const otherUpload = await routes.merchantUploadDocument(context, fixture, other, other.document,
key(context, `mixed-tax-upload-${other.customerId}`))
context.assert(MODULE, '第二退税客户独立上传资料', otherUpload,
success(otherUpload) && zeroDrift(otherUpload.body?.data?.inboundIds, other.inboundIds), evidence(otherUpload), OPTIONS)
if (!success(otherUpload)) blocker(context, '第二客户退税资料', '本轮第二客户资料上传失败,禁止继续开始')
other.documentId = id(otherUpload.body.data.documentId)
}
const ready = await platformDetail(context, routes, fixture)
context.assert(MODULE, 'TAX票全部覆盖后平台准确进入READY_TO_START', ready,
success(ready) && ready.body?.data?.status === 'READY_TO_START'
@@ -372,22 +554,12 @@ async function runMixedConcurrency(context, routes, fixture) {
const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
{ ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, {
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
}, key(context, 'mixed-receipt-confirm'))
context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm,
success(receiptConfirm) && success(receiptConfirmReplay)
&& receiptConfirm.body?.data?.status === 'DECLARING'
&& receiptConfirmReplay.body?.data?.status === 'DECLARING',
[receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS)
const receiptConfirm = await verifyMixedReceiptIsolation(context, routes, fixture, receipt)
const submitted = await routes.platformSubmitToBroker(context, fixture,
{ expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker'))
context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted,
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
await verifyFormalDeclarationAndExceptions(context, routes, fixture)
const finalRequest = {
documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-mixed-customs-tax-final.pdf`,
@@ -408,6 +580,19 @@ async function runMixedConcurrency(context, routes, fixture) {
{ ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey)
context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS)
const partiallyCompleted = await platformDetail(context, routes, fixture)
context.assert(MODULE, '首个客户完成文件不能提前完成多客户整柜', partiallyCompleted,
success(partiallyCompleted) && partiallyCompleted.body?.data?.status === 'DECLARING', evidence(partiallyCompleted), OPTIONS)
for (const other of fixture.customers.slice(1)) {
const otherFinal = await routes.platformUploadFinal(context, fixture, {
documentId: other.documentId, expectedVersion: 0,
fileName: `${context.marker}-${other.customerId}-final.customblob`,
contentType: 'application/octet-stream', content: Uint8Array.from([0x46, 0x32]),
}, key(context, `mixed-final-${other.customerId}`))
context.assert(MODULE, '其他客户真实完成文件独立回传', otherFinal,
success(otherFinal) && zeroDrift(otherFinal.body?.data?.inboundIds, other.inboundIds), evidence(otherFinal), OPTIONS)
}
let completed = await platformDetail(context, routes, fixture)
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
@@ -451,6 +636,19 @@ async function runMixedConcurrency(context, routes, fixture) {
context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
for (const other of fixture.customers.slice(1)) {
const ownDetail = await routes.merchantDetail(context, fixture, other)
context.assert(MODULE, '整柜完成后第二客户详情只包含自身资料与完成文件', ownDetail,
success(ownDetail) && ownDetail.body?.data?.documents?.length === 1
&& ownDetail.body.data.documents.every((item) => id(item.tenantId) === other.customerId
&& id(item.documentId) === other.documentId && item.finalDocument?.fileId),
evidence(ownDetail), OPTIONS)
const foreignDownload = await routes.merchantDownloadFinal(context, fixture, other, {
documentId: customer.documentId,
})
context.assert(MODULE, '第二客户不能下载其他客户完成文件', foreignDownload,
foreignDownload.http === 200 && code(foreignDownload) === 'A0404', evidence(foreignDownload), OPTIONS)
}
}
export async function run(context) {
@@ -1,7 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { markerSuffix } from './phase3-loading.mjs'
import { loadingBookingCustomerFields, markerSuffix } from './phase3-loading.mjs'
import { withPlatformBookingScope } from './phase3-container-customs-fixture.mjs'
test('normal loading keeps customer scope; explicit platform booking omits both customer fields', () => {
const fields = loadingBookingCustomerFields(undefined, '2100000000000000001', '测试客户')
assert.deepEqual(fields, { customerId: '2100000000000000001', customerName: '测试客户' })
assert.deepEqual(loadingBookingCustomerFields('CUSTOMER', fields.customerId, fields.customerName), fields)
const platform = loadingBookingCustomerFields('PLATFORM', fields.customerId, fields.customerName)
assert.equal(Object.hasOwn(platform, 'customerId'), false)
assert.equal(Object.hasOwn(platform, 'customerName'), false)
assert.throws(() => loadingBookingCustomerFields('MISSPELLED', fields.customerId, fields.customerName), /订舱范围/)
})
test('mixed fixture platform scope restores absence and an existing value after success or failure', async () => {
const absent = { context: {} }
assert.equal(await withPlatformBookingScope(absent, async () => {
assert.equal(absent.context.phase3LoadingBookingScope, 'PLATFORM')
return 'loaded'
}), 'loaded')
assert.equal(Object.hasOwn(absent.context, 'phase3LoadingBookingScope'), false)
const existing = { context: { phase3LoadingBookingScope: 'CUSTOMER' } }
await assert.rejects(withPlatformBookingScope(existing, async () => {
assert.equal(existing.context.phase3LoadingBookingScope, 'PLATFORM')
throw new Error('actual loading failure')
}), /actual loading failure/)
assert.equal(existing.context.phase3LoadingBookingScope, 'CUSTOMER')
})
test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => {
const base = 'ADMIN-API-20260824123024536'
@@ -18,6 +44,7 @@ test('phase3 loading uses the collision-safe marker suffix for generated busines
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const suffix = markerSuffix\(context\.marker\)/)
assert.match(source, /const bookingDraft = \{\s*\.\.\.loadingBookingCustomerFields\(context\.context\?\.phase3LoadingBookingScope, tenantId,/)
assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/)
assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/)
assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/)
@@ -13,6 +13,11 @@ const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1'
function id(value) { return value == null ? '' : String(value) }
export function loadingBookingCustomerFields(scope = 'CUSTOMER', customerId, customerName) {
if (scope === 'PLATFORM') return {}
if (scope !== 'CUSTOMER') throw new Error('三期订舱范围必须明确为 CUSTOMER 或 PLATFORM')
return { customerId, customerName }
}
export function markerSuffix(marker) {
const value = String(marker || '')
const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0')
@@ -201,7 +206,8 @@ export async function run(context) {
supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true,
}
const bookingDraft = {
customerId: tenantId, customerName: initial?.summary?.customerName,
...loadingBookingCustomerFields(context.context?.phase3LoadingBookingScope, tenantId,
initial?.summary?.customerName),
warehouseId,
channelCode: channelGroup(initial?.summary?.channelCode),
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
@@ -569,6 +575,13 @@ export async function run(context) {
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS)
if (context.context?.phase3LoadingBookingScope === 'PLATFORM') {
const platformScoped = bookingDraftResponse?.oceanBooking?.customerId == null
&& bookingDraftResponse?.oceanBooking?.customerName == null
context.assert(MODULE, '多客户混柜使用未绑定单一客户的平台订舱', bookingCreateResponse,
platformScoped, 'scope=PLATFORM', OPTIONS)
if (!platformScoped) return
}
const bookingId = id(bookingDraftResponse?.oceanBooking?.id)
const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version)
context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse,
@@ -646,6 +659,12 @@ export async function run(context) {
&& id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
if (context.context?.phase3LoadingBookingScope === 'PLATFORM') {
const platformScoped = linked?.customerId == null && linked?.customerName == null
context.assert(MODULE, '平台订舱关联后原柜保持跨客户装柜范围', linkResponse,
platformScoped, 'scope=PLATFORM', OPTIONS)
if (!platformScoped) return
}
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse,
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
@@ -1,5 +1,6 @@
import test from 'node:test'
import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
@@ -114,3 +115,191 @@ test('manual exception retries only a transport failure with the identical busin
assert.match(source, /人工异常连接失败后使用同载荷重试/)
assert.match(source, /manualExceptionBody/)
})
test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => {
const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3,
holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' }
assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }]))
for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) {
assert.notDeepEqual(inventoryQuantitySnapshot([original]),
inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }]))
}
})
function contextFor(request) {
const assertions = []
const calls = []
return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {},
assertions, calls,
async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) },
apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data },
assert(module, name, response, passed) { assertions.push({ name, passed }) },
addStep(module, name, response, passed) { assertions.push({ name, passed }) },
addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) },
}
}
const ok = data => ({ http: 200, body: { code: '200', data } })
test('partial completion rejects different Orders and an already delivered pending fragment', async () => {
for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' },
{ orderId: 'ORDER', processStatus: 'DELIVERED' }]) {
const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A')
? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }]
: [{ inboundCode: 'IN-B', ...second }] }))
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false)
assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false)
}
})
test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => {
for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) {
const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/')
? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B',
orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] }))
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED')
}
})
test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => {
for (const initialStatus of ['PENDING', 'PROCESSING']) {
let completed = false
let version = initialStatus === 'PROCESSING' ? 1 : 0
let uploads = 0
const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }
const context = contextFor((method, path, body) => {
if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1',
customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }])
if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100',
overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8,
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] })
if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2',
customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1',
outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] })
if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] })
if (method === 'POST' && path.endsWith('/pallets')) return ok('71')
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) }
if (path.includes('/complete')) {
assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1,
targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' })
const replayed = completed; completed = true
return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed })
}
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } })
return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN',
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] })
})
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
const resume = initialStatus === 'PROCESSING'
? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null
const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume })
assert.equal(result?.restoredPackages, 1)
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2)
if (initialStatus === 'PROCESSING') {
assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false)
assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false)
assert.equal(uploads, 0)
assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE'))
}
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
}
})
test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => {
const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 },
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }))
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
assert.equal(await completeReturnedException(context,
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null)
assert.equal(context.calls.length, 1)
assert.ok(context.assertions.some(row => !row.passed))
})
test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => {
const context = contextFor((method, path) => {
if (path.includes('/inventory/')) return ok({ records: [], total: 0 })
if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 })
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } }
return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 },
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })
})
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' }
assert.equal(await completeReturnedException(context,
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null)
assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2)
assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2)
assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed))
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE')
assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false)
})
test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => {
const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile')
? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN'
? { http: 404, body: { code: 'A0404' } }
: ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] }))
const previousFetch = globalThis.fetch
globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN'
? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } })
: new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } })
try {
assert.equal(await verifyCustomerSignedDocuments(context, 'OWN',
[{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true)
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false)
} finally { globalThis.fetch = previousFetch }
})
test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => {
const context = contextFor((method, path, body, token) => {
assert.equal(path, '/api/auth/login')
assert.equal(token, '')
assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' })
return ok({ accessToken: 'SECONDARY-TOKEN' })
})
context.options.customerName = 'PRIMARY'
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
const primary = context.context.merchantQualificationFixture
const options = context.options
const create = async (secondary, flags) => {
assert.equal(flags.publishEnvironment, false)
assert.equal(secondary.marker, `${context.marker}-SIGNED-B`)
assert.notEqual(secondary.options, context.options)
assert.notEqual(secondary.context, context.context)
assert.notEqual(secondary.createdResources, context.createdResources)
assert.equal(secondary.options.customerName, '')
secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY',
password: 'SECONDARY-IN-MEMORY', marker: secondary.marker }
// Even unexpected producer fields cannot enter the report's explicit metadata whitelist.
secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' }
}
assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN')
assert.equal(context.options, options)
assert.equal(context.options.customerName, 'PRIMARY')
assert.equal(context.context.merchantQualificationFixture, primary)
assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN')
const report = JSON.stringify(context.createdResources)
assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/)
assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B')
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
})
test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => {
const context = contextFor(() => { throw new Error('must not log in') })
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} })
assert.equal(token, null)
assert.equal(context.calls.length, 0)
assert.ok(context.assertions.some(row => !row.passed))
assert.equal(context.context.phase5TailVerification, undefined)
})
@@ -1,4 +1,5 @@
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
const MODULE = 'phase5-overseas-fulfillment'
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
@@ -66,6 +67,221 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
return { result, attempts }
}
async function allPages(context, path, values, name) {
const rows = []
for (let page = 1; page <= 50; page += 1) {
const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 }))
if (!success(current.response) || !Array.isArray(current.data?.records)) return null
rows.push(...current.data.records)
const total = Number(current.data.total)
if (Number.isInteger(total) && rows.length >= total) {
const complete = rows.length === total && rows.every(row => nonBlank(id(row.id)))
&& new Set(rows.map(row => id(row.id))).size === rows.length
context.assert(MODULE, `${name}无分页遗漏或重复`, current.response,
complete,
`rows=${rows.length}, total=${total}`, OPTIONS)
return complete ? rows : null
}
if (current.data.records.length === 0) break
}
blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据')
return null
}
export function inventoryQuantitySnapshot(rows) {
return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode,
totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages),
holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages),
pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id))
}
async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) {
const rows = []
for (const inboundCode of [...new Set(inboundCodes)]) {
const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`,
{ customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`)
if (!current) return null
// The server's text filter may match child codes; inventory evidence must match the exact ticket.
rows.push(...current.filter(row => row.inboundCode === inboundCode
&& id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId))
}
return inventoryQuantitySnapshot(rows)
}
async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) {
const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`,
{ customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name)
if (!rows) return null
const exact = rows.filter(row => id(row.customerId) === customerId
&& row.outboundOrderNo === outboundNo && row.businessType === businessType
&& (!businessOrderNo || row.businessOrderNo === businessOrderNo))
context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length,
`rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS)
return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode,
palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages),
beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) }))
.sort((a, b) => a.id.localeCompare(b.id))
}
const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right)
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof.
export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
|| !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0
|| pendingInboundCodes.includes(deliveredInboundCode)) {
blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源')
return false
}
const rows = []
for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) {
const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`,
query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 }))
rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode))
}
const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId))
const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED'
&& rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus))
context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null,
validPartialCompletion,
`codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS)
if (!validPartialCompletion) return false
const current = await platform(context, 'GET', '未全签收时回查订单完成门禁',
query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId }))
const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED'
context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response,
blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
return sameOrder && blocked
}
export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) {
const owned = context.createdResources?.phase5OverseasFulfillment
if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) {
blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号')
return null
}
const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId })
const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath)
const order = before.data?.order
const sources = before.data?.returnSources
if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN'
|| order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) {
blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response)
return null
}
const continuing = order.status === 'PROCESSING'
const previousAttempt = resume || owned.returnReceiptAttempt
if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId))
|| !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId))
|| (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo)
|| (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) {
blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response)
return null
}
const source = sources[0]
const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存')
const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo)
if (!stockBefore || !ledgerBefore) return null
const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}`
const palletCode = continuing ? previousAttempt.targetPalletNo
: `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128)
let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : ''
if (!continuing) {
const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`,
{ palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` })
targetPalletId = id(created.data)
if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null
}
const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`)
const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位')
const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId))
context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response,
id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode
&& Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS)
if (!location?.locationCode) return null
let processingVersion = Number(order.version)
let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : ''
if (!continuing) {
const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' }
const started = await platform(context, 'POST', '开始处理同一派送退回异常',
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody)
if (!success(started.response) || started.data?.status !== 'PROCESSING') return null
processingVersion = Number(started.data.version)
const evidence = await context.requestMultipart('POST',
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }),
{ name: `${context.marker}-return-receipt.txt`, type: 'text/plain',
content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) })
const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS)
if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null
evidenceFileId = id(uploaded.fileId)
}
const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey)
? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE')
if (!Number.isInteger(processingVersion) || processingVersion < 0) {
blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response)
return null
}
owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode,
evidenceFileId, completeIdempotencyKey }
const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey,
handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId],
receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId),
receivedPackages: 1, targetPalletId, targetPalletNo: palletCode,
targetLocationId: id(location.id), targetLocationCode: location.locationCode }] }
const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId })
const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody)
context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response,
completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1
&& /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS)
if (!success(completed.response)) {
const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存')
const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo)
context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response,
sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS)
return null
}
const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存')
const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo)
const targetPositions = []
for (const balance of stockAfter || []) {
const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置',
query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode }))
if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode))
}
context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null,
targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode
&& id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id)
&& id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId
&& Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1,
`targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS)
const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id))
context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null,
stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1
&& newRows?.length === 1 && newRows[0].deltaPackages === 1
&& newRows[0].afterPackages - newRows[0].beforePackages === 1,
`before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS)
const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody)
const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存')
const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo)
context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response,
replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId)
&& sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS)
const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath)
const delivery = await platform(context, 'GET', '回查退回实收后的原派送单',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId }))
context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response,
final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo
&& final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId)
&& final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId)
&& id(row.inventoryPositionId) === id(source.inventoryPositionId))
&& delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo,
trace(final.response), OPTIONS)
return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId,
inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages),
targetPositionIds: targetPositions.map(row => id(row.id)) }
}
export async function saveReceiptAndComplete(context, delivery, customerId, index) {
const deliveryNo = id(delivery.deliveryNo)
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
@@ -86,6 +302,11 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse,
/^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS)
if (!success(uploadResponse) || !fileId) return null
if (context.createdResources.phase5OverseasFulfillment) {
const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= []
receipts.push({ deliveryNo, fileId,
content: `${context.marker} receipt acceptance for ${deliveryNo}` })
}
const existing = (before.data?.categories || []).map((category) => ({
category: category.category,
@@ -115,6 +336,112 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null
}
async function receiptBinary(context, token, path) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000),
})
const bytes = new Uint8Array(await response.arrayBuffer())
const contentType = response.headers.get('content-type') || ''
let body = null
if (/json/i.test(contentType)) {
try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ }
}
return { http: response.status, body, bytes, contentType,
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
} catch (error) {
return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) }
}
}
const hiddenOrForbidden = response => [403, 404].includes(response?.http)
|| (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response)))
export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) {
// Own options, credentials and resource storage prevent the secondary fixture from changing the main customer.
const secondary = Object.create(context)
secondary.marker = `${context.marker}-SIGNED-B`
secondary.options = { ...context.options, customerName: '' }
secondary.context = { ...context.context }
secondary.createdResources = {}
await create(secondary, { publishEnvironment: false })
const fixture = secondary.context?.merchantQualificationFixture
const safe = secondary.createdResources?.merchantQualificationFixture
const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName,
username: safe?.username, marker: safe?.marker }
const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker
&& /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId)
&& nonBlank(fixture?.username) && nonBlank(fixture?.password)
context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null,
ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS)
if (!ready) {
blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代')
return null
}
context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata
const login = await context.request('POST', '/api/auth/login',
{ loginId: fixture.username, password: fixture.password }, '')
const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS)
if (!success(login) || !nonBlank(auth?.accessToken)) {
blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login)
return null
}
context.context.phase5TailVerification = { ...context.context.phase5TailVerification,
foreignMerchantToken: auth.accessToken }
return auth.accessToken
}
export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) {
if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) {
blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话')
return false
}
let verified = true
if (foreignToken) {
const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile')
const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile')
const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id)
context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response,
distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS)
if (!distinct) return false
}
for (const receipt of receiptFiles) {
const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt`
const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base)
const bound = detail.data?.deliveryNo === receipt.deliveryNo
&& detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId))
context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS)
const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download`
const download = await receiptBinary(context, merchantToken, downloadPath)
const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType)
&& new TextDecoder().decode(download.bytes) === receipt.content
context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS)
verified &&= Boolean(bound && exactBytes)
const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo)
if (otherReceipt) {
const wrongFile = await receiptBinary(context, merchantToken,
`${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`)
context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile,
hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS)
verified &&= hiddenOrForbidden(wrongFile)
}
if (foreignToken) {
const deniedDetail = await context.request('GET', base, undefined, foreignToken, {})
context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail,
hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS)
const deniedFile = await receiptBinary(context, foreignToken, downloadPath)
context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile,
hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS)
verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile)
}
}
if (!foreignToken) {
blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝')
return false
}
return verified
}
export function relabelOutboundMarksMatch(detail, relabel) {
const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '')
.split(',').map(mark => mark.trim()).filter(Boolean))
@@ -349,16 +676,40 @@ export async function run(context) {
const access = await resolvePickerAccess(context)
const picker = access ? await createPicker(context, 5, warehouseId, access) : null
const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))]
if (dispatchInboundCodes.length === 0) {
blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码')
await disablePicker(context, picker, 5)
return
}
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存')
// Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks.
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水')
try {
if (!nonBlank(picker?.token)) {
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
return
}
if (!await pickAll(context, picker.token, dispatchNo)) return
if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存')
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水')
const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id))
const expectedPackages = Number(transferred.data.packageCount)
context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null,
stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages
&& expectedPackages > 0 && shippedRows?.length > 0
&& -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages
&& shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages),
`before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS)
const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response,
ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS)
if (!success(ready.response) || ready.data?.canDepart !== true) return
const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` }
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response,
context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response,
departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0,
trace(departed.response), OPTIONS)
if (!success(departed.response)) return
@@ -368,6 +719,16 @@ export async function run(context) {
departReplay.data?.replayed === true
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
trace(departReplay.response), OPTIONS)
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存')
const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水')
context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response,
sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
context.createdResources.phase5OverseasFulfillment.inventoryProof = {
inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick),
afterPackages: quantityTotal(stockAfterDepart), expectedPackages,
shipmentLedgerIds: (shippedRows || []).map(row => row.id),
canDepart: ready.data?.canDepart === true,
}
} finally {
await disablePicker(context, picker, 5)
if (picker) picker.token = null
@@ -453,6 +814,17 @@ export async function run(context) {
context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result,
success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED',
`orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS)
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态',
`/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`)
context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response,
customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED'
&& id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId,
trace(customerCompletedOrder.response), OPTIONS)
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|| await createSignedDocumentIsolationCustomer(context)
await verifyCustomerSignedDocuments(context, merchantToken,
context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken)
const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果',
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), {
@@ -531,5 +903,9 @@ export async function run(context) {
exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo)
&& exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo),
`records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS)
context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT'
const returnReceipt = await completeReturnedException(context, { customerId, warehouseId,
exceptionNo: returnExceptionNo, outboundNo })
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
}