This commit is contained in:
2026-09-30 15:16:39 +08:00
parent 0919a61661
commit 22584e7fde
15 changed files with 994 additions and 45 deletions
+4 -2
View File
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url'
import process from 'node:process' import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify',
@@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
} }
const MODULE_DEPENDENCIES = new Map([ const MODULE_DEPENDENCIES = new Map([
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
['pda-loading-link-regression', ['merchant-qualification-fixture']], ['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']],
@@ -343,6 +344,7 @@ export function parseArguments(argv) {
const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || ''
const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || ''
const requiresMarkerOwnedMerchantFixture = [ const requiresMarkerOwnedMerchantFixture = [
'phase5-root-fulfillment',
'phase4-overseas-business', 'phase4-overseas-business',
'phase4-overseas-return-receipt', 'phase4-overseas-return-receipt',
'phase4-overseas-client-services', 'phase4-overseas-client-services',
@@ -399,7 +401,7 @@ export function validateExecutionOptions(options) {
} }
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported') if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
if ('overseasGateway' in options) throw new Error('Use the single gateway option') if ('overseasGateway' in options) throw new Error('Use the single gateway option')
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading'].includes(module)) if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module))
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
} }
+2
View File
@@ -4,6 +4,8 @@
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数;缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { completeDropshipPalletization } from './inbound-measurement.mjs' import { completeDropshipPalletization, verifyMeasurementSubmitReplay,
verifyMeasurementApprovalReplay } from './inbound-measurement.mjs'
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
@@ -191,3 +192,131 @@ test('pregenerated pallet regression detects duplicate replay and orphan empty p
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet') const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed)) assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
}) })
function measurementReplayContext(approved = false, mutate = () => {}, submitCode = 'A0409') {
const input = { inboundId: '9007199254740993101', tenantId: '9007199254740993102',
measurementId: '9007199254740993103' }
const state = {
measurement: { inboundId: input.inboundId, measurementId: input.measurementId, inboundCode: 'REPLAY-IN',
measurementStatus: approved ? 'APPROVED' : 'SUBMITTED', measureReviewStatus: approved ? 'APPROVED' : 'PENDING',
processStatus: approved ? 'WAIT_SPLIT' : 'MEASURE_PENDING', version: 3, inboundVersion: 4,
actualPackageCount: 4, actualGrossWeightKg: 100, actualVolumeCbm: 1,
items: [{ id: '9007199254740993104', packageCount: 4, boxCode: 'REPLAY-IN#4-1' }],
weightResults: [{ id: '9007199254740993105', fileId: '9007199254740993115', grossWeightKg: 100 }],
palletLocationGuide: { blockId: '9007199254740993106', slots: [{ reservationId: '9007199254740993107' }] } },
inbound: { summary: { id: input.inboundId, tenantId: input.tenantId, version: 4 },
statusHistory: [{ id: '9007199254740993108', action: approved ? 'MEASURE_APPROVE' : 'MEASURE_SUBMIT' }] },
reviews: [{ id: '9007199254740993109', measurementId: input.measurementId, reviewResult: 'APPROVED' }],
fee: { feeId: '9007199254740993110', feeVersion: 1, inboundId: input.inboundId,
measurementId: input.measurementId, feeStatus: 'CALCULATED', totalAmount: 77 },
details: [{ id: '9007199254740993111', sourceFeeId: '9007199254740993110', sourceFeeVersion: 1,
inboundId: input.inboundId, amount: 77 }],
}
const calls = []
const context = { marker: 'MEASUREMENT-REPLAY', token: 'memory-only-test-token',
request: async (method, requestPath, body, token, headers) => {
calls.push({ method, requestPath, body: structuredClone(body), token, headers })
if (method === 'POST') {
mutate(state)
if (requestPath.includes('/measurement/submit?')) return {
http: 200, body: { code: submitCode }, requestId: 'submit-repeat', traceId: 'trace-submit',
}
assert.match(requestPath, /\/measurements\/approve\?/)
return { http: 200, body: { code: '200', data: {
initialPricingSucceeded: true, initialFee: structuredClone(state.fee),
} }, requestId: 'approve-repeat', traceId: 'trace-approve' }
}
let data
if (requestPath.includes('/measurement?')) data = state.measurement
else if (requestPath.startsWith(`/api/platform/inbounds/${input.inboundId}?`)) data = state.inbound
else if (requestPath.includes('/measurement-review-history?')) data = {
records: state.reviews, total: state.reviews.length, page: 1, size: 200,
}
else if (requestPath.includes('/fees/latest?')) data = state.fee
else if (requestPath.includes('/fee-details?')) {
assert.equal(new URL(requestPath, 'https://example.invalid').searchParams.get('includeHistory'), 'true')
data = state.details
} else throw new Error(`unexpected ${method} ${requestPath}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' }
},
apiSuccess: (_module, name, response) => {
assert.equal(response.http, 200, name); assert.equal(response.body.code, '200', name); return response.body.data
},
assert: (_module, name, _response, passed) => assert.ok(passed, name),
}
return { input, state, context, calls }
}
test('submitted measurement repeats the original payload, requires conflict and preserves all readable facts', async () => {
const fixture = measurementReplayContext()
const payload = { measurementId: fixture.input.measurementId, version: 2 }
const evidence = await verifyMeasurementSubmitReplay(fixture.context, fixture.input, payload)
assert.equal(evidence.rejected, true); assert.equal(evidence.unchanged, true)
assert.equal(evidence.beforeDigest, evidence.afterDigest)
assert.equal(evidence.historyCount, 1)
assert.deepEqual(fixture.calls.find(call => call.method === 'POST').body, payload)
assert.equal(fixture.calls.find(call => call.method === 'POST').headers, undefined)
assert.equal(Object.hasOwn(evidence, 'weightResults'), false)
})
for (const [name, mutate] of [
['new batch', state => { state.measurement.measurementId = '9007199254740993199' }],
['batch version', state => { state.measurement.version += 1 }],
['extra item', state => state.measurement.items.push({ id: '9007199254740993198', packageCount: 1 })],
['changed weight', state => { state.measurement.weightResults[0].grossWeightKg += 1 }],
['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993197' })],
]) {
test(`duplicate measurement submission detects ${name}`, async () => {
const fixture = measurementReplayContext(false, mutate)
await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, version: 2 }), /零漂移|原入库原批次/)
})
}
test('duplicate measurement submission must not accept a successful mutation response', async () => {
const fixture = measurementReplayContext(false, () => {}, '200')
await assert.rejects(verifyMeasurementSubmitReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, version: 2 }), /再次提交明确A0409/)
})
test('approval replay uses the original key and old expectedVersion and compares all fee versions and histories', async () => {
const fixture = measurementReplayContext(true)
const payload = { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }
const evidence = await verifyMeasurementApprovalReplay(fixture.context, fixture.input, payload, 'original-review-key')
const post = fixture.calls.find(call => call.method === 'POST')
assert.deepEqual(post.body, payload)
assert.equal(post.headers['Idempotency-Key'], 'original-review-key')
assert.equal(evidence.unchanged, true)
assert.equal(evidence.beforeDigest, evidence.afterDigest)
assert.equal(evidence.feeId, fixture.state.fee.feeId)
assert.equal(evidence.feeVersion, 1)
assert.equal(evidence.reviewIds.length, 1)
assert.equal(evidence.feeDetailIds.length, 1)
assert.equal(JSON.stringify(evidence).includes(fixture.context.token), false)
})
for (const [name, mutate] of [
['different fee ID', state => { state.fee.feeId = '9007199254740993191' }],
['extra fee version', state => { state.fee.feeVersion += 1 }],
['extra fee detail', state => state.details.push({ ...state.details[0], id: '9007199254740993192' })],
['changed historical fee detail', state => { state.details[0].amount += 1 }],
['duplicate review record', state => state.reviews.push({ ...state.reviews[0], id: '9007199254740993193' })],
['extra inbound history', state => state.inbound.statusHistory.push({ id: '9007199254740993194' })],
]) {
test(`approval replay detects ${name} despite successful response`, async () => {
const fixture = measurementReplayContext(true, mutate)
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
/零新增|唯一审核及已生成初算费用事实有效/)
})
}
test('review history uses the real reviewResult field and rejects a fabricated result alias', async () => {
const fixture = measurementReplayContext(true)
fixture.state.reviews[0] = { id: '9007199254740993109', measurementId: fixture.input.measurementId, result: 'APPROVED' }
await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input,
{ measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'),
/唯一审核及已生成初算费用事实有效/)
assert.equal(fixture.calls.some(call => call.method === 'POST'), false)
assert.match(source, /item\.reviewResult === 'APPROVED'/)
})
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto'
const MODULE = 'inbound-measurement' const MODULE = 'inbound-measurement'
const SCENARIO = 'mojia-carton-measurement' const SCENARIO = 'mojia-carton-measurement'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -28,6 +30,110 @@ function validPdf(response) {
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF' && response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
} }
export function measurementReplayDigest(facts) {
return createHash('sha256').update(JSON.stringify(facts)).digest('hex')
}
async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
const measurement = context.apiSuccess(MODULE, `${name}测量批次明细及重量事实`, response, '', OPTIONS)
const inboundResponse = await context.request('GET', `/api/platform/inbounds/${inboundId}?${query(tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${name}入库状态及历史`, inboundResponse, '', OPTIONS)
const valid = asId(measurement?.inboundId) === inboundId && asId(measurement?.measurementId) === measurementId
&& Array.isArray(measurement?.items) && measurement.items.length > 0
&& Array.isArray(measurement?.weightResults) && measurement.weightResults.length > 0
&& new Set(measurement.items.map(item => asId(item.id))).size === measurement.items.length
&& new Set(measurement.weightResults.map(item => asId(item.id))).size === measurement.weightResults.length
&& asId(inbound?.summary?.id) === inboundId && asId(inbound?.summary?.tenantId) === tenantId
&& Array.isArray(inbound?.statusHistory)
context.assert(MODULE, `${name}原入库原批次明细及历史可核对`, response, valid,
`measurementId=${asId(measurement?.measurementId)}, items=${measurement?.items?.length ?? 'missing'}`
+ `, weights=${measurement?.weightResults?.length ?? 'missing'}`, OPTIONS)
if (!valid) throw new Error('测量重放核验缺少原批次或完整明细历史事实')
return { response, facts: { measurement, inbound } }
}
export async function verifyMeasurementSubmitReplay(context, input, payload) {
const before = await readMeasurementReplayFacts(context, input, '重复提交前')
const response = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${input.inboundId}/measurement/submit?${query(input.tenantId)}`, payload)
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
context.assert(MODULE, '已提交测量再次提交明确A0409', response, rejected,
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, OPTIONS)
const after = await readMeasurementReplayFacts(context, input, '重复提交后')
const beforeDigest = measurementReplayDigest(before.facts)
const afterDigest = measurementReplayDigest(after.facts)
const unchanged = beforeDigest === afterDigest
context.assert(MODULE, '重复测量提交拒绝后批次版本明细重量及入库历史零漂移', after.response,
unchanged, `items=${after.facts.measurement.items.length}, weights=${after.facts.measurement.weightResults.length}`
+ `, histories=${after.facts.inbound.statusHistory.length}`, OPTIONS)
return { measurementId: input.measurementId, rejected, unchanged, beforeDigest, afterDigest,
itemIds: after.facts.measurement.items.map(item => asId(item.id)),
weightIds: after.facts.measurement.weightResults.map(item => asId(item.id)),
measurementVersion: after.facts.measurement.version,
inboundVersion: after.facts.measurement.inboundVersion, historyCount: after.facts.inbound.statusHistory.length }
}
export async function verifyMeasurementApprovalReplay(context, input, payload, idempotencyKey) {
async function read(name) {
const snapshot = await readMeasurementReplayFacts(context, input, name)
const reviews = []; let total = null; let page = 1
while (total == null || reviews.length < total) {
const response = await context.request('GET',
`/api/platform/v1/inbounds/${input.inboundId}/measurement-review-history?${query(input.tenantId)}&page=${page}&size=200`)
const data = context.apiSuccess(MODULE, `${name}审核历史第${page}页`, response, '', OPTIONS)
const valid = Array.isArray(data?.records) && Number.isInteger(Number(data?.total)) && Number(data.total) >= 0
&& (total == null || total === Number(data.total))
&& (data.records.length > 0 || Number(data.total) === 0)
context.assert(MODULE, `${name}审核历史完整分页`, response, valid,
`page=${page}, total=${data?.total ?? 'missing'}`, OPTIONS)
if (!valid) throw new Error('测量审核重放缺少完整审核历史')
total = Number(data.total); reviews.push(...data.records); page += 1
}
const feeResponse = await context.request('GET',
`/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`)
const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS)
const detailsResponse = await context.request('GET',
`/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`)
const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS)
const approvedReviewCount = reviews.filter(item => asId(item.measurementId) === input.measurementId
&& item.reviewResult === 'APPROVED').length
const valid = snapshot.facts.measurement.measurementStatus === 'APPROVED'
&& snapshot.facts.measurement.measureReviewStatus === 'APPROVED'
&& reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total
&& approvedReviewCount === 1
&& Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1
&& Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length
context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid,
`reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}`
+ `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}`
+ `, measurementStatus=${snapshot.facts.measurement.measurementStatus}`
+ `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS)
if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实')
return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } }
}
const before = await read('审核同键重放前')
const response = await context.request('POST',
`/api/platform/v1/inbounds/${input.inboundId}/measurements/approve?${query(input.tenantId)}`,
payload, context.token, { 'Idempotency-Key': idempotencyKey })
const replay = context.apiSuccess(MODULE, '测量审核同原键原请求重放成功', response, '', OPTIONS)
const after = await read('审核同键重放后')
const beforeDigest = measurementReplayDigest(before.facts)
const afterDigest = measurementReplayDigest(after.facts)
const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true
&& asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId)
&& Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion)
context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response,
unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}`
+ `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS)
return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest,
feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion,
feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)),
reviewIds: after.facts.reviews.map(item => asId(item.id)),
inboundHistoryCount: after.facts.inbound.statusHistory.length }
}
async function reusablePallets(context, warehouseId) { async function reusablePallets(context, warehouseId) {
const cacheKey = asId(warehouseId) const cacheKey = asId(warehouseId)
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
@@ -1055,14 +1161,17 @@ export async function run(context) {
} }
} }
const submissionPayload = { measurementId: draft.measurementId, version: Number(draft.version) }
const replayInput = { inboundId, tenantId, measurementId: asId(draft.measurementId) }
const submitResponse = await context.request('POST', const submitResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`, `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
{ measurementId: draft.measurementId, version: Number(draft.version) }) submissionPayload)
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse, const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS) `measurementId=${asId(draft.measurementId)}`, OPTIONS)
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse, context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING', submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS) `processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
const submissionReplay = await verifyMeasurementSubmitReplay(context, replayInput, submissionPayload)
const rejectedScanResponse = await multipartRequest(context, scanPath, { const rejectedScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19), boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
@@ -1081,9 +1190,12 @@ export async function run(context) {
`status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS) `status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS)
const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}` const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}`
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, { const approvalPayload = {
measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null, measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null,
}, { 'Idempotency-Key': `${context.marker}-mojia-approve`.slice(0, 128) }) }
const approvalKey = `${context.marker}-mojia-approve`.slice(0, 128)
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, approvalPayload,
{ 'Idempotency-Key': approvalKey })
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse, context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS) `measurementId=${asId(draft.measurementId)}`, OPTIONS)
@@ -1107,6 +1219,8 @@ export async function run(context) {
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight), && closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
const approvalReplay = await verifyMeasurementApprovalReplay(context, replayInput, approvalPayload, approvalKey)
let sealedCartonMarks = [] let sealedCartonMarks = []
let sealedSourcePalletNos = [] let sealedSourcePalletNos = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
@@ -1156,6 +1270,7 @@ export async function run(context) {
OPTIONS) OPTIONS)
context.createdResources.inboundMeasurement = { context.createdResources.inboundMeasurement = {
replayEvidence: { submission: submissionReplay, approval: approvalReplay },
sealedCartonMarks, sealedSourcePalletNos, sealedCartonMarks, sealedSourcePalletNos,
marker: context.marker, tenantId, inboundId, marker: context.marker, tenantId, inboundId,
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId), orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { approvedInboundReady } from './orders.mjs' import { approvedInboundReady, verifyOrderApprovalReplay } from './orders.mjs'
const tenantId = '10001' const tenantId = '10001'
const orderId = '20001' const orderId = '20001'
@@ -48,3 +48,76 @@ test('orders acceptance polls approval convergence instead of only inbound id vi
assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/) assert.doesNotMatch(source, /MEASURABLE_STATUSES\.has\(summary\?\.processStatus\)/)
assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/) assert.doesNotMatch(source, /if \(success\(response\) && response\.body\?\.data\?\.summary\?\.inboundId\)/)
}) })
function approvalReplayContext(mutate = () => {}, approvalCode = 'A0409') {
const inboundId = '40001'; const inboundCode = 'AT-APPROVAL'
const state = {
order: { order: { id: orderId, status: 'APPROVED', inboundCode, version: 2 },
cargoes: [], parties: [], internalNotes: [], statusHistory: [{ id: 'history-order', action: 'APPROVED' }] },
task: { id: '30001', tenantId, orderId, status: 'WAIT_MEASURE' },
inbound: { summary: { id: inboundId, tenantId, inboundCode, version: 2, processStatus: 'WAIT_MEASURE' },
statusHistory: [{ id: 'history-inbound', action: 'ORDER_APPROVED' }], files: [], palletLocations: [] },
rows: [{ id: inboundId, tenantId, orderId, inboundCode }],
}
const calls = []; const checks = []
const context = { marker: 'APPROVAL-REPLAY', createdResources: { orders: {} },
request: async (method, requestPath, body, token, headers) => {
calls.push({ method, requestPath, body, token, headers })
if (method === 'POST') {
assert.match(requestPath, /\/orders\/first-leg\/20001\/approve\?tenantId=10001$/)
assert.equal(body, undefined); assert.equal(headers, undefined)
mutate(state)
return { http: 200, body: { code: approvalCode }, requestId: 'repeat-approve', traceId: 'trace-approve' }
}
let data
if (requestPath.includes('/orders/first-leg/')) data = state.order
else if (requestPath.includes('/tasks/by-order/')) data = state.task
else if (requestPath.startsWith(`/api/platform/inbounds/${inboundId}?`)) data = state.inbound
else if (requestPath.startsWith('/api/platform/inbounds?')) data = {
records: state.rows, page: 1, size: 100, total: state.rows.length, pages: 1,
}
else throw new Error(`unexpected ${method} ${requestPath}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'read', traceId: 'trace' }
},
apiSuccess: (_module, _name, response) => {
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
},
assert: (_module, name, _response, passed) => {
checks.push({ name, passed }); assert.ok(passed, name)
},
}
return { context, calls, checks, input: { tenantId, orderId, inboundId, inboundCode } }
}
test('repeated approval requires A0409 and compares order, task, inbound identity, histories and unique count', async () => {
const fixture = approvalReplayContext()
const result = await verifyOrderApprovalReplay(fixture.context, fixture.input)
assert.equal(result.rejected, true)
assert.equal(result.unchanged, true)
assert.equal(result.inboundCount, 1)
assert.equal(result.orderHistoryCount, 1)
assert.equal(result.inboundHistoryCount, 1)
assert.equal(result.beforeDigest, result.afterDigest)
assert.equal(fixture.calls.filter(call => call.method === 'POST').length, 1)
assert.equal(fixture.context.createdResources.orders.approvalReplay, result)
assert.equal(Object.hasOwn(result, 'order'), false)
assert.match(result.beforeDigest, /^[a-f0-9]{64}$/)
})
for (const [name, mutate] of [
['extra approval history', state => state.order.statusHistory.push({ id: 'repeat-history', action: 'APPROVED' })],
['changed inbound version', state => state.inbound.summary.version += 1],
['extra inbound history', state => state.inbound.statusHistory.push({ id: 'repeat-inbound-history' })],
['new fulfillment task', state => { state.task.id = '30002' }],
['duplicate source inbound', state => state.rows.push({ ...state.rows[0], id: '40002' })],
]) {
test(`approval replay detects ${name} instead of accepting unchanged response status`, async () => {
const fixture = approvalReplayContext(mutate)
await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /零漂移|唯一入库事实有效/)
})
}
test('successful repeated approval is not accepted as a substitute for the formal conflict contract', async () => {
const fixture = approvalReplayContext(() => {}, '200')
await assert.rejects(verifyOrderApprovalReplay(fixture.context, fixture.input), /再次审核明确A0409/)
})
+69
View File
@@ -86,6 +86,72 @@ export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderI
&& id(task?.orderId) === id(orderId) && id(task?.orderId) === id(orderId)
&& id(task?.tenantId) === id(tenantId) && id(task?.tenantId) === id(tenantId)
} }
export function approvalReplayDigest(snapshot) {
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex')
}
export async function verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode }) {
const scope = `tenantId=${encodeURIComponent(tenantId)}`
const orderPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${scope}`
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?${scope}`
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?${scope}`
async function read(name) {
const orderResponse = await context.request('GET', orderPath)
const detail = context.apiSuccess(MODULE, `${name}订单及状态历史`, orderResponse, '', CONTRACT_OPTIONS)
const taskResponse = await context.request('GET', taskPath)
const task = context.apiSuccess(MODULE, `${name}原履约任务`, taskResponse, '', CONTRACT_OPTIONS)
const inboundResponse = await context.request('GET', inboundPath)
const inbound = context.apiSuccess(MODULE, `${name}原入库单及历史`, inboundResponse, '', CONTRACT_OPTIONS)
const rows = []; let total = null; let page = 1
while (total == null || rows.length < total) {
const response = await context.request('GET', `/api/platform/inbounds?${scope}`
+ `&inboundCode=${encodeURIComponent(inboundCode)}&page=${page}&size=100`)
const data = context.apiSuccess(MODULE, `${name}入仓码来源入库列表第${page}页`, response, '', CONTRACT_OPTIONS)
const valid = pageOk(data) && Number.isInteger(Number(data.total)) && Number(data.total) >= 0
&& (total == null || total === Number(data.total))
&& (data.records.length > 0 || Number(data.total) === 0)
context.assert(MODULE, `${name}入库分页计数完整`, response, valid,
`page=${page}, total=${data?.total ?? 'missing'}`, CONTRACT_OPTIONS)
if (!valid) throw new Error('审核重放核验缺少完整入库分页事实')
total = Number(data.total); rows.push(...data.records); page += 1
}
const exact = rows.filter(row => id(row.inboundCode) === inboundCode)
const valid = detail?.order?.status === 'APPROVED' && id(detail.order.id) === orderId
&& id(detail.order.inboundCode) === inboundCode && Array.isArray(detail.statusHistory)
&& id(task?.orderId) === orderId && id(task?.tenantId) === tenantId && Boolean(id(task?.id))
&& id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
&& id(inbound?.summary?.inboundCode) === inboundCode && Array.isArray(inbound?.statusHistory)
&& rows.length === total && new Set(rows.map(row => id(row.id))).size === total
&& exact.length === 1 && id(exact[0].id) === inboundId
context.assert(MODULE, `${name}审核态原身份与唯一入库事实有效`, inboundResponse, valid,
`exactInbounds=${exact.length}, taskId=${id(task?.id)}, inboundId=${id(inbound?.summary?.id)}`, CONTRACT_OPTIONS)
if (!valid) throw new Error('审核重放核验的订单任务或唯一入库身份不一致')
return { response: inboundResponse, facts: {
order: JSON.parse(orderPersistenceFacts(detail)), task, inbound, exactInbounds: exact,
} }
}
const before = await read('审核重放前')
const response = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?${scope}`)
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
context.assert(MODULE, '已审核订单再次审核明确A0409', response, rejected,
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
const after = await read('审核重放后')
const beforeDigest = approvalReplayDigest(before.facts)
const afterDigest = approvalReplayDigest(after.facts)
const unchanged = beforeDigest === afterDigest
context.assert(MODULE, '审核重复拒绝后订单入仓码历史任务及唯一入库事实零漂移', after.response,
unchanged, `inboundCount=1, orderHistories=${after.facts.order.statusHistory.length}`
+ `, inboundHistories=${after.facts.inbound.statusHistory.length}`, CONTRACT_OPTIONS)
const evidence = { marker: context.marker, tenantId, orderId, inboundId, inboundCode,
taskId: id(after.facts.task.id), inboundCount: after.facts.exactInbounds.length,
orderHistoryCount: after.facts.order.statusHistory.length,
inboundHistoryCount: after.facts.inbound.statusHistory.length,
beforeDigest, afterDigest, rejected, unchanged }
context.createdResources.orders.approvalReplay = evidence
return evidence
}
function tomorrow(days = 30) { function tomorrow(days = 30) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days) const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10)
@@ -742,6 +808,9 @@ export async function run(context) {
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接') throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法修改申报并向 OCR/墨家模块交接')
} }
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
const editableDetailResponse = await context.request('GET', firstLegDetailPath) const editableDetailResponse = await context.request('GET', firstLegDetailPath)
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse) const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
@@ -4,7 +4,36 @@ import path from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs' import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer,
crossContainerCartonProbe } from './phase4-overseas-pda.mjs'
test('same-root FBA cross-container rejection uses explicit real receiving prefix while preserving original physical box code',() => {
const current={containerNo:'ROOT-CABINET',inbounds:[{inboundCode:'ROOT'}]}
const child={containerNo:'CHILD-CABINET',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201',
sourceStatus:'LOADED',inboundCode:'CHILD',destinationType:'FBA',fulfillmentType:'STANDARD',
cartonMarks:['ROOT#2-1'],receivingCartonMarks:['CHILD#PDA-CONTRACT-1']}
const before=JSON.stringify(child),probe=crossContainerCartonProbe([child],current)
assert.equal(probe.cartonMark,'CHILD#PDA-CONTRACT-1')
assert.equal(probe.mode,'FBA_RECEIVING_PREFIX');assert.equal(probe.sourceOrderId,'101')
assert.equal(JSON.stringify(child),before)
for(const fault of [
{...child,receivingCartonMarks:undefined}, {...child,receivingCartonMarks:['ROOT#PDA-CONTRACT-1']},
{...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-']}, {...child,receivingCartonMarks:['CHILD#PDA-CONTRACT-0']},
{...child,containerNo:'ROOT-CABINET'}, {...child,sourceOrderId:null}, {...child,sourceInboundId:null},
{...child,containerBookingId:null}, {...child,sourceStatus:'WAIT_SPLIT'}, {...child,inboundCode:'ROOT'},
])assert.equal(crossContainerCartonProbe([fault],current),null)
assert.equal(crossContainerCartonProbe([],current),null)
assert.equal(crossContainerCartonProbe([child],{containerNo:'ROOT-CABINET',inbounds:[]}),null)
})
test('physical OVERSEAS and dropship probes still require another real loaded producer and keep the exact sealed code',() => {
const other={containerNo:'OTHER',containerBookingId:'20',sourceOrderId:'101',sourceInboundId:'201',
sourceStatus:'LOADED',inboundCode:'OTHER-SOURCE',destinationType:'OVERSEAS',fulfillmentType:'STANDARD',
cartonMarks:['ORIGINAL-PHYSICAL#2-1']}
const current={containerNo:'CURRENT',inbounds:[{inboundCode:'CURRENT-SOURCE'}]}
assert.equal(crossContainerCartonProbe([other],current).cartonMark,'ORIGINAL-PHYSICAL#2-1')
assert.equal(crossContainerCartonProbe([other],current).mode,'PHYSICAL_CARTON')
assert.equal(crossContainerCartonProbe([{...other,sourceInboundId:null}],current),null)
})
test('receiving selects one current booking and supplies mandatory booking identity', () => { test('receiving selects one current booking and supplies mandatory booking identity', () => {
assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/) assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/)
@@ -14,6 +14,27 @@ export function uniqueContainerProducer(item, index, all) {
return nonBlank(item?.containerNo) return nonBlank(item?.containerNo)
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index && all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
} }
export function crossContainerCartonProbe(producers, current) {
const currentCodes = new Set((current?.inbounds || []).map(item => id(item.inboundCode)))
if (!nonBlank(current?.containerNo) || !currentCodes.size) return null
for (const item of producers || []) {
if (!nonBlank(item?.containerNo) || item.containerNo === current?.containerNo
|| !longId(item?.containerBookingId) || !longId(item?.sourceOrderId)
|| !longId(item?.sourceInboundId) || item?.sourceStatus !== 'LOADED'
|| !nonBlank(item?.inboundCode) || currentCodes.has(item.inboundCode)) continue
const standardFba = item.destinationType === 'FBA' && item.fulfillmentType === 'STANDARD'
const marks = standardFba ? item.receivingCartonMarks
: (Array.isArray(item.cartonMarks) ? item.cartonMarks : [item.cartonMark])
const prefix = `${item.inboundCode}#PDA-CONTRACT-`
const cartonMark = (Array.isArray(marks) ? marks : []).map(id).find(value => nonBlank(value)
&& (!standardFba || (value.startsWith(prefix) && /^[1-9][0-9]*$/.test(value.slice(prefix.length)))))
if (cartonMark) return { cartonMark, containerNo:item.containerNo,
containerBookingId:id(item.containerBookingId), inboundCode:item.inboundCode,
sourceOrderId:id(item.sourceOrderId), sourceInboundId:id(item.sourceInboundId),
mode:standardFba ? 'FBA_RECEIVING_PREFIX' : 'PHYSICAL_CARTON' }
}
return null
}
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function code(response) { return String(response?.body?.code ?? '') } function code(response) { return String(response?.body?.code ?? '') }
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
@@ -525,14 +546,13 @@ async function probeCartonResolveNegatives(context, token, current) {
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || [] const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerMark = allProducers const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
.filter((item) => id(item?.containerNo) !== id(current.containerNo)) if (crossContainerProbe) {
.flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark]) context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true,
.map(id).find(nonBlank) JSON.stringify(crossContainerProbe),OPTIONS)
if (crossContainerMark) {
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝', await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: crossContainerMark }, cartonResolveRejected) { cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected)
} else { } else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
} }
@@ -1,5 +1,5 @@
import test from 'node:test' import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
@@ -74,7 +74,9 @@ test('phase-five module proves receipt-save completion, cross-region convergence
assert.match(source, /query\('\/api\/platform\/inbounds'/) assert.match(source, /query\('\/api\/platform\/inbounds'/)
assert.match(source, /inboundCode: inbound\.inboundCode/) assert.match(source, /inboundCode: inbound\.inboundCode/)
assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/)
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) assert.match(source, /verifyFulfillmentRootStatus\(context, \{ sourceOrderId, customerId, merchantToken \}\)/)
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(rootOrderId\)\}/)
assert.match(source, /\/fulfillment-tree/)
assert.match(source, /mainStatus === 'COMPLETED'/) assert.match(source, /mainStatus === 'COMPLETED'/)
assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
assert.match(source, /manualExceptionNo/) assert.match(source, /manualExceptionNo/)
@@ -550,3 +552,108 @@ test('G05 failures still independently review and preserve raw, group, each SHIP
assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter) assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter)
} }
}) })
function rootCompletionFixture({ childStatus = 'PROCESSING', rootStatus = 'PROCESSING', rootDelivered = true,
fullyLoaded = false, mainStatus = rootStatus === 'COMPLETED' ? 'COMPLETED' : 'DELIVERED', sourceOrderId = '1001' } = {}) {
const root = { order: { id: '1001', tenantId: '100', customerId: '100', orderRole: 'MASTER', rootOrderId: '1001',
orderNo: 'FL1001', status: rootStatus }, statusHistory: [
...(rootDelivered ? [{ action: 'OVERSEAS_DELIVERED' }] : []),
...(rootStatus === 'COMPLETED' ? [{ action: 'FULFILLMENT_COMPLETED' }] : [])] }
const child = { order: { id: '1002', tenantId: '100', customerId: '100', orderRole: 'CHILD',
rootOrderId: '1001', parentOrderId: '1001', orderNo: 'FL1001-1', status: childStatus } }
const tree = { parentOrderId: '1001', parentOrderNo: 'FL1001', originalPackageCount: 4,
cumulativeLoadedPackageCount: fullyLoaded ? 4 : 1, currentRemainderPackageCount: fullyLoaded ? 0 : 3,
children: [{ orderId: '1001', orderNo: 'FL1001', splitRole: 'LOADED', sourceOrderId: '1001', packageCount: 1 },
{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'REMAINDER', sourceOrderId: '1001', packageCount: 3 },
...(fullyLoaded ? [{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'LOADED', sourceOrderId: '1002', packageCount: 3 }] : [])] }
const context = contextFor((method, path) => {
if (path.includes('/fulfillment-tree')) return ok(tree)
if (path.includes('/first-leg/1002')) return ok(child)
if (path.includes('/first-leg/1001')) return ok(root)
return ok({ summary: { orderId: '1001', mainStatus } })
})
context.createdResources.phase5OverseasFulfillment = {}
return { root, child, tree, context,
args: { sourceOrderId, customerId: '100', merchantToken: 'ONLY-IN-MEMORY', pollAttempts: 1, pollIntervalMs: 0 } }
}
test('real root1 signed plus effective remainder3 keeps raw PROCESSING and both platform/client DELIVERED', async () => {
const fixture = rootCompletionFixture()
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
assert.equal(result.passed, true)
assert.equal(result.expectedCompleted, false)
assert.deepEqual(result.pendingChildren.map(row => row.orderId), ['1002'])
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
const proof = fixture.context.createdResources.phase5OverseasFulfillment.orderCompletionProof
assert.equal(proof.tree.originalPackageCount, 4)
assert.equal(proof.tree.currentRemainderPackageCount, 3)
assert.deepEqual(proof.pendingChildOrderIds, ['1002'])
assert.doesNotMatch(JSON.stringify(proof), /ONLY-IN-MEMORY|password|accessToken|Bearer/)
})
test('missing, foreign, unknown or omitted real child cannot be treated as all fulfillment completed', async () => {
for (const mutate of [
fixture => { fixture.child.order = null },
fixture => { fixture.child.order.customerId = 'FOREIGN' },
fixture => { fixture.child.order.rootOrderId = 'OTHER' },
fixture => { fixture.child.order.parentOrderId = 'OTHER' },
fixture => { fixture.child.order.orderNo = 'WRONG' },
fixture => { fixture.child.order.status = undefined },
fixture => { fixture.child.order.status = 'UNKNOWN' },
fixture => { fixture.tree.children.pop() },
fixture => { fixture.tree.children = undefined },
]) {
const fixture = rootCompletionFixture(); mutate(fixture)
assert.equal(await verifyFulfillmentRootStatus(fixture.context, fixture.args), null)
assert.ok(fixture.context.assertions.some(row => !row.passed))
assert.equal(fixture.context.calls.some(call => call.path.startsWith('/api/merchant/')), false)
}
})
test('draft, rejected and pending child stay effective, and a premature root COMPLETED still fails', async () => {
for (const childStatus of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'PROCESSING']) {
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED' })
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
assert.equal(result.expectedCompleted, false)
assert.equal(result.passed, false)
assert.equal(result.pendingChildren.length, 1)
assert.ok(fixture.context.assertions.some(row => !row.passed))
}
})
test('actual completed child or explicit CANCELLED permits completion only with root own receipt and raw completion fact', async () => {
for (const childStatus of ['COMPLETED', 'CANCELLED']) {
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED', fullyLoaded: childStatus === 'COMPLETED' })
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
assert.equal(result.expectedCompleted, true)
assert.equal(result.passed, true)
assert.deepEqual(result.pendingChildren, [])
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
}
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', fullyLoaded: true, rootDelivered: false })
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
assert.equal(result.expectedCompleted, false)
assert.equal(result.passed, true)
})
test('receipt of a real child resolves and checks its owning root instead of completing the child as the aggregate', async () => {
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, sourceOrderId: '1002' })
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
assert.equal(result.rootOrderId, '1001')
assert.equal(result.passed, true)
assert.ok(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1001'))
assert.equal(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1002'), false)
})
test('all fragments completed still requires raw aggregate history and matching platform/customer completion', async () => {
const missingHistory = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
missingHistory.root.statusHistory = [{ action: 'OVERSEAS_DELIVERED' }]
assert.equal((await verifyFulfillmentRootStatus(missingHistory.context, missingHistory.args)).passed, false)
const notProjected = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, mainStatus: 'DELIVERED' })
assert.equal((await verifyFulfillmentRootStatus(notProjected.context, notProjected.args)).passed, false)
const foreignProjection = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
const originalRequest = foreignProjection.context.request
foreignProjection.context.request = async (...args) => args[1].startsWith('/api/merchant/')
? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args)
assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false)
})
@@ -299,6 +299,102 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di
return { passed, proof, newLedger } return { passed, proof, newLedger }
} }
// Completion follows the formal root and every real child; a signed shipment alone is not the aggregate.
export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken,
pollAttempts = 45, pollIntervalMs = 1_000 }) {
const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId })
const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId))
const sourceOrder = source.data?.order
const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId)
const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId
&& id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId
&& /^[1-9][0-9]*$/.test(rootOrderId)
&& (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId))
context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS)
if (!sourceOwned) return null
let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId))
const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树',
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId }))
const tree = treeResult.data
const rows = Array.isArray(tree?.children) ? tree.children : []
const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))])
const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId)))
const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0)
const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId)))
.reduce((sum, row) => sum + Number(row.packageCount), 0)
const treeComplete = success(root.response) && success(treeResult.response)
&& id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER'
&& id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId
&& Array.isArray(root.data.statusHistory)
&& id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo
&& Array.isArray(tree.children)
&& ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field =>
tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0)
&& loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount)
&& loaded + remaining === Number(tree.originalPackageCount)
&& rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId))
&& treeOrderIds.has(id(row.sourceOrderId))
&& nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole)
&& (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId))
&& Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0)
&& (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId))
context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete,
`rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS)
if (!treeComplete) return null
const children = []
const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED']
for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) {
const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId))
const child = current.data?.order
const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId
&& id(child?.customerId) === customerId && child?.orderRole === 'CHILD'
&& id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId
&& rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo)
&& statuses.includes(child?.status)
context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS)
if (!owned) return null
children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId })
}
const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status))
const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true
const expectedCompleted = rootDelivered && pendingChildren.length === 0
const owned = context.createdResources.phase5OverseasFulfillment
if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId,
tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount),
cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining,
children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId),
splitRole: row.splitRole, packageCount: Number(row.packageCount) })) },
children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) }
const domestic = await poll(() => context.request('GET',
query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })),
response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId
&& (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED'
: rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED'
: nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs)
if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId))
const rawPassed = success(root.response) && (expectedCompleted
? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')
: statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED'
&& !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED'))
const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId
&& (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED'
: rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED'
: nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED')
context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED',
root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS)
context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态',
domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`)
const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId
&& customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed
context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED',
customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS)
if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status,
platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus,
rawPassed: Boolean(rawPassed), platformPassed, customerPassed })
return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) }
}
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. // 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 }) { export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
@@ -1012,19 +1108,8 @@ export async function run(context) {
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
return return
} }
const domesticOrder = await poll(async () => context.request('GET',
query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })),
(response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED')
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 context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态', await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
`/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 const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|| await createSignedDocumentIsolationCustomer(context) || await createSignedDocumentIsolationCustomer(context)
await verifyCustomerSignedDocuments(context, merchantToken, await verifyCustomerSignedDocuments(context, merchantToken,
@@ -0,0 +1,202 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, run } from './phase5-root-fulfillment.mjs'
import { loadOneFragment } from './phase3-order-splitting.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolveSharedWarehouse } from './phase4-overseas-pda.mjs'
const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8')
const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action}))
const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2,
currentRemainderPackageCount:0,children:[
{orderId:'100',sourceOrderId:'100',splitRole:'LOADED',packageCount:1},
{orderId:'101',sourceOrderId:'100',splitRole:'REMAINDER',packageCount:1},
{orderId:'101',sourceOrderId:'101',splitRole:'LOADED',packageCount:1},
]})
const signed = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')},
child:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED')},
rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}})
const outbox = () => ({readOnly:true,customerId:'90',rootOrderId:'100',orders:['100','101'].map(orderId => ({
orderId,customerId:'90',rootOrderId:'100',eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',
count:1,publishedCount:1,pendingCount:0,failedCount:0,
}))})
const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdResources:{},
addStep(_module,_label,_response,passed){this.steps.push({passed})},
assert(_module,_label,_response,passed){this.steps.push({passed})},
addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data},
})
test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => {
const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8')
const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'}
const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now)
for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) {
assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field])
}
assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03')
assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03')
assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP')
assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托')
for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field])
assert.equal(body.fees.length,1)
assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])),
{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10})
assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/)
assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/)
})
test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => {
const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'}
const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'}
assert.deepEqual(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:current}}),
{username:'fixture-customer',password:'fixture-secret',customerId:'90'})
assert.deepEqual(resolveF40MerchantCredentials({options,context:{}}),
{username:'configured-customer',password:'configured-secret',customerId:null})
for(const incomplete of [{username:'fixture-only',tenantId:'90'},{password:'fixture-only',tenantId:'90'},
{username:'fixture-customer',password:'fixture-secret'}])
assert.equal(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:incomplete}}),null)
assert.equal(resolveF40MerchantCredentials({options:{merchantUsername:'incomplete'},context:{}}),null)
assert.ok(source.indexOf("'F40 正式订单创建客户与本轮登录客户一致'") < source.indexOf("await stage(scope, 'inbound-measurement')"))
})
test('F40 reads formal nested domestic cabinet workbench and compares booking/plan/task identity instead of inventing GET containers/id',() => {
const controller=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java',import.meta.url),'utf8')
const dto=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java',import.meta.url),'utf8')
assert.match(controller,/@GetMapping\("\/domestic\/\{id\}"\)/)
assert.doesNotMatch(controller,/@GetMapping\("\/\{id\}"\)/)
assert.match(dto,/record WorkbenchItem\(ContainerBookingEntity container, String workbenchStage/)
assert.match(source,/`\/api\/platform\/v1\/containers\/domestic\/\$\{result.containerId\}`/)
assert.doesNotMatch(source,/`\/api\/platform\/v1\/containers\/\$\{result.containerId\}`/)
const fragment={containerId:'20',planId:'30',taskId:'40',oceanBookingId:'50'},task={id:'40',containerBookingId:'20',loadingPlanId:'30'}
const workbench={container:{id:'20',cabinetNo:'D0001',containerNo:'OFFLINE',oceanBookingId:'50'}}
assert.equal(validFragmentCabinetIdentity(workbench,fragment,task),true)
assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,containerId:'21'},task),false)
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,containerBookingId:'21'}),false)
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,loadingPlanId:'31'}),false)
assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,id:'41'}),false)
assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,oceanBookingId:'51'},task),false)
assert.equal(validFragmentCabinetIdentity(workbench.container,fragment,task),false)
})
test('formal tree requires one root physical source plus its exact real remainder child and conserves two boxes',() => {
assert.equal(validTwoFragmentTree(tree(),'100','101'),true)
for (const change of [
value => value.parentOrderId='999', value => value.currentRemainderPackageCount=1,
value => value.children[2].sourceOrderId='999', value => value.children[1].sourceOrderId='999',
value => value.children[2].packageCount=2,
value => value.children.push({splitRole:'UNKNOWN',orderId:'999'}),
]) {const value=tree();change(value);assert.equal(validTwoFragmentTree(value,'100','101'),false)}
})
test('F40 separates immutable physical task cartons from current actual FBA receiving prefix scans',() => {
assert.match(source,/cartonMarks: stableLoadedCartonMarks\(task.items\)/)
assert.match(source,/receivingCartonMarks:\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT-1`\]/)
assert.doesNotMatch(source,/cartonMarks:\s*\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT/)
})
test('raw Order and original status history remain in evidence including operator, reason and timestamp',() => {
const detail={order:{id:'100',orderRole:'MASTER',version:3,status:'TRANSPORTING'},statusHistory:[
{id:'11',orderId:'100',action:'OVERSEAS_DELIVERED',operatorName:'OVERSEAS_DELIVERY_EVENT',
reason:'signed actual delivery',occurredAt:'2026-09-30T01:00:00',fromStatus:'TRANSPORTING',toStatus:'TRANSPORTING'},
]}
const snapshot=fulfillmentHistorySnapshot(detail)
assert.deepEqual(snapshot.rawOrder,detail.order)
assert.deepEqual(snapshot.histories,detail.statusHistory)
assert.deepEqual(completionCounts(snapshot),{own:1,final:0})
})
test('root shipment signed first preserves unfinished root while effective child remains pending',() => {
const snapshot=signed();snapshot.root.status='TRANSPORTING'
snapshot.root.histories=history('OVERSEAS_DELIVERED');snapshot.child={status:'TRANSPORTING',histories:[]}
snapshot.rootInbound.orderStatusSnapshot='TRANSPORTING'
assert.equal(validSignatureGate(snapshot,'MASTER'),true)
snapshot.root.status='COMPLETED'
assert.equal(validSignatureGate(snapshot,'MASTER'),false)
})
test('child signed before root remains an independent completed child and cannot finish unsigned root',() => {
const snapshot=signed();snapshot.root={status:'TRANSPORTING',histories:[]}
snapshot.rootInbound={processStatus:'IN_TRANSIT',orderStatusSnapshot:'TRANSPORTING'}
assert.equal(validSignatureGate(snapshot,'CHILD'),true)
assert.equal(validSignatureGate(snapshot,'MASTER'),false)
snapshot.root.histories=history('OVERSEAS_DELIVERED')
assert.equal(validSignatureGate(snapshot,'CHILD'),false)
})
test('all signed requires root own fact, exactly one aggregate final history and real Firstleg delivered completion',() => {
assert.equal(validSignatureGate(signed(),'MASTER',true),true)
for(const change of [
value => value.root.histories=history('FULFILLMENT_COMPLETED'),
value => value.root.histories.push({action:'FULFILLMENT_COMPLETED'}),
value => value.child.histories.push({action:'OVERSEAS_DELIVERED'}),
value => value.child.status='CANCELLED',
value => value.rootInbound.orderStatusSnapshot='TRANSPORTING',
value => value.rootInbound.processStatus='RECEIVED',
]) {const value=signed();change(value);assert.equal(validSignatureGate(value,'MASTER',true),false)}
})
test('Outbox requires exact customer/root/source aggregate membership, unique completion and PUBLISHED',() => {
assert.equal(exactCompletionOutboxProof(outbox(),'90','100',['101','100']),true)
for(const change of [
value => value.readOnly=false, value => value.customerId='91', value => value.rootOrderId='999',
value => value.orders[0].rootOrderId='999', value => value.orders[0].customerId='91',
value => value.orders[1].orderId='100', value => value.orders[0].count=2,
value => value.orders[1].publishedCount=0, value => value.orders[1].pendingCount=1,
value => value.orders[0].failedCount=1, value => value.orders[0].eventType='OVERSEAS_DELIVERED',
value => value.orders.pop(),
]) {const value=outbox();change(value);assert.equal(exactCompletionOutboxProof(value,'90','100',['100','101']),false)}
})
test('exported fragment producer preserves fail closed supplier precondition and never writes without actual source master',async() => {
const context=fixture(),calls=[]
context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}}
assert.equal(await loadOneFragment(context,{sourceOrderNo:'offline-only'}),null)
assert.equal(calls.length,1)
assert.equal(calls[0][0],'GET')
assert.match(calls[0][1],/category=BOOKING&status=ACTIVE/)
})
test('exported pallet label helper preserves FBA eligibility without introducing writes for other destinations',async() => {
const context=fixture();context.request=async() => {throw new Error('unexpected HTTP')}
assert.equal(await verifyPalletLabels(context,{destinationType:'OVERSEAS'},'1'),null)
assert.equal(await verifyPalletLabels(context,{destinationType:'FBA',fulfillmentType:'ONE_CLICK_DROPSHIP'},'1'),null)
})
test('exported consumer polling and conversion fail closed when real producer consumption is absent',async() => {
const context=fixture(),calls=[]
context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}}
assert.equal(await awaitProducerConsumption(context,[]),true)
assert.equal(await convertReceivingContainers(context,[{containerNo:'OFFLINE'}]),false)
assert.equal(calls.every(([method]) => method==='GET'),true)
assert.equal(context.steps.at(-1).passed,false)
})
test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => {
const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data:
path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}})
await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false)
assert.equal(context.steps.some(step => step.passed === false),true)
})
test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => {
const context=fixture();context.request=async() => ({http:200,body:{code:200,data:{records:[
{id:'193000000000200003',warehouseType:'OVERSEAS',status:'ACTIVE',warehouseName:'offline'},
]}}})
assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'})
})
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback, SQL or cancellation substitute',() => {
assert.equal(typeof run,'function')
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`))
for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption',
'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete'])
assert.match(source,new RegExp(`await ${name}\\(`))
assert.match(source,/rootOrderId, sourceOrderIds:\[rootOrderId,childOrderId\]/)
assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/)
assert.match(source,/finally \{ proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/)
assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/cancel|\/complete-delivery/)
})
test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => {
const context=fixture();context.context={};context.options={}
await assert.rejects(run(context),/F40 本轮客户完整凭据有效/)
assert.equal(context.createdResources.phase5RootFulfillment.finalStatus,'FAILED')
assert.equal(context.createdResources.phase5RootFulfillment.failedStage,'CUSTOMER_IDENTITY')
assert.equal('error' in context.createdResources.phase5RootFulfillment,false)
const secretError=new Error('offline-secret-body-not-recorded')
const withLogin=fixture();withLogin.context={};withLogin.options={merchantUsername:'offline',merchantPassword:'offline'}
withLogin.request=async()=>{throw secretError}
await assert.rejects(run(withLogin),error=>error === secretError)
assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED')
assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false)
})
@@ -14,6 +14,40 @@ const id = value => value == null ? '' : String(value)
const success = response => response?.http === 200 && String(response?.body?.code) === '200' const success = response => response?.http === 200 && String(response?.body?.code) === '200'
const query = (path, values) => `${path}?${new URLSearchParams(values)}` const query = (path, values) => `${path}?${new URLSearchParams(values)}`
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
export function resolveF40MerchantCredentials(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture) {
const source = context.options
if (!source?.merchantUsername || !source?.merchantPassword) return null
return { username:source.merchantUsername, password:source.merchantPassword, customerId:null }
}
if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null
return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) }
}
export function f40TransferBody(marker, index, vehicle, now = new Date()) {
return {
idempotencyKey:`${marker}-F40-TRANSFER-${index}`,
plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)),
deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64),
carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET',
vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName,
driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate,
appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`,
fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10,
remark:`${marker}-acceptance`}],
}
}
export function validFragmentCabinetIdentity(workbench, fragment, task) {
const container = workbench?.container
return id(container?.id) === id(fragment?.containerId)
&& Boolean(container?.cabinetNo && container?.containerNo)
&& id(container?.oceanBookingId) === id(fragment?.oceanBookingId)
&& id(task?.id) === id(fragment?.taskId)
&& id(task?.containerBookingId) === id(fragment?.containerId)
&& id(task?.loadingPlanId) === id(fragment?.planId)
}
export function fulfillmentHistorySnapshot(detail) { export function fulfillmentHistorySnapshot(detail) {
return { return {
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
@@ -124,9 +158,12 @@ async function fragment(context, source, rootOrderId, sequence) {
check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`)
const current = await api(context, 'GET', '回查该片真实入仓来源', const current = await api(context, 'GET', '回查该片真实入仓来源',
query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) }))
const container = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/${result.containerId}`) const workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`)
const container = workbench?.container
const task = await api(context, 'GET', '回查该片真实完成 PDA 任务', const task = await api(context, 'GET', '回查该片真实完成 PDA 任务',
query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) })) query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) }))
check(context,'F40 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致',
validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`)
check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED' check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED'
&& task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1, && task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1,
`source=${current?.summary?.inboundCode}`) `source=${current?.summary?.inboundCode}`)
@@ -138,6 +175,8 @@ async function fragment(context, source, rootOrderId, sequence) {
warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo, warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo,
destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId), destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId),
destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items), destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items),
// Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged.
receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`],
finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true, finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true,
} }
context.createdResources.phase3Loading = loading context.createdResources.phase3Loading = loading
@@ -167,14 +206,15 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
planPalletIds: matches.map(row => id(row.planPalletId)), planPalletIds: matches.map(row => id(row.planPalletId)),
}) })
const dispatchNo = dispatch.dispatchNo const dispatchNo = dispatch.dispatchNo
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, { const vehicle = {
vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机',
driverPhone: '19900000000', carrierName: 'F40测试车队', driverPhone: '19900000000', carrierName: 'F40测试车队',
estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)),
}) }
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle)
const transfer = await api(context, 'POST', '客户正式创建该片派送单', const transfer = await api(context, 'POST', '客户正式创建该片派送单',
query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }),
{ idempotencyKey: `${context.marker}-F40-TRANSFER-${index}`, remark: context.marker }, merchantToken) f40TransferBody(context.marker,index,vehicle), merchantToken)
const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`)
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
@@ -200,15 +240,29 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index) const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index)
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
receiptVersion: completed.order.version } receiptVersion: completed.order.version, signedAt: completed.order.signedAt }
} }
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ /** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
export async function run(context) { export async function run(context) {
try { return await runRootFulfillment(context) }
catch (error) {
const proof = context.createdResources?.phase5RootFulfillment
if (proof) {
proof.finalStatus = 'FAILED'
proof.failedStage ||= proof.currentStage || 'INITIALIZING'
}
throw error
}
}
async function runRootFulfillment(context) {
const initialStepCount = context.steps?.length || 0
const proof = context.createdResources.phase5RootFulfillment = { const proof = context.createdResources.phase5RootFulfillment = {
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null, marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null,
fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null, fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null,
currentStage:'INITIALIZING', failedStage:null,
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
} }
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
@@ -219,11 +273,26 @@ export async function run(context) {
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' }, orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
phase4OverseasPdaOperations: { completedFixtures: [] },
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } } scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } }
proof.currentStage = 'CUSTOMER_IDENTITY'
const credentials = resolveF40MerchantCredentials(scope)
check(scope,'F40 本轮客户完整凭据有效', Boolean(credentials), 'complete-credential-pair')
const login = await api(scope,'POST','F40 本轮客户正式登录','/api/auth/login',
{loginId:credentials.username,password:credentials.password},'')
const profile = await api(scope,'GET','F40 本轮客户会话归属核验',
'/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken)
const expectedCustomerId = id(profile.id)
check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId)
&& (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer')
await domesticLogin(scope) await domesticLogin(scope)
proof.currentStage = 'ORDER_CREATION'
await stage(scope, 'orders') await stage(scope, 'orders')
const orderFixture = { ...scope.createdResources.orders } const orderFixture = { ...scope.createdResources.orders }
check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId,
'actual-order-customer-matches-profile')
proof.currentStage = 'DOMESTIC_MEASUREMENT'
await stage(scope, 'inbound-measurement') await stage(scope, 'inbound-measurement')
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
proof.rootOrderId = rootOrderId; proof.customerId = customerId proof.rootOrderId = rootOrderId; proof.customerId = customerId
@@ -231,8 +300,10 @@ export async function run(context) {
query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId })) query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId }))
check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT' check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT'
&& Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`)
proof.currentStage = 'ROOT_LOAD_AND_SHIP'
const first = await fragment(scope, rootInbound, rootOrderId, 0) const first = await fragment(scope, rootInbound, rootOrderId, 0)
proof.fragments.push(first) proof.fragments.push(first)
proof.currentStage = 'REMAINDER_IDENTITY'
const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || [] const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || []
@@ -253,8 +324,10 @@ export async function run(context) {
const childInbound = await api(scope, 'GET', '读取子单可装柜来源', const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
scope.marker = `${scope.context.f40BaseMarker}-CHILD` scope.marker = `${scope.context.f40BaseMarker}-CHILD`
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
const second = await fragment(scope, childInbound, rootOrderId, 1) const second = await fragment(scope, childInbound, rootOrderId, 1)
proof.fragments.push(second) proof.fragments.push(second)
proof.currentStage = 'FULFILLMENT_TREE'
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId) check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
@@ -264,26 +337,25 @@ export async function run(context) {
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
scope.marker = `${scope.context.f40BaseMarker}-PDA` scope.marker = `${scope.context.f40BaseMarker}-PDA`
const picker = await createPicker(scope, 7, warehouse.id, access) const picker = await createPicker(scope, 7, warehouse.id, access)
try {
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
picker.warehouseId = warehouse.id picker.warehouseId = warehouse.id
try {
const loginId = scope.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
const password = scope.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
const login = await api(scope, 'POST', 'F40 客户正式登录', '/api/auth/login', { loginId, password }, '')
const profile = await api(scope, 'GET', 'F40 客户会话归属核验', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken)
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] } scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] }
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null } overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null }
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed') check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed')
check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted') check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted')
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second] const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second]
for (const [index, producer] of signatureSources.entries()) { for (const [index, producer] of signatureSources.entries()) {
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker
scope.createdResources.phase4OverseasPda.marker = scope.marker scope.createdResources.phase4OverseasPda.marker = scope.marker
@@ -295,15 +367,21 @@ export async function run(context) {
&& scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row => && scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row =>
id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo), id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo),
`container=${producer.containerNo}`) `container=${producer.containerNo}`)
proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}`
const signed = await sendFragment(scope,producer,picker,login.accessToken,index) const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
proof.signatures.push(signed) proof.signatures.push(signed)
proof.currentStage = `COMPLETION_GATE_${index+1}`
const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId), const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId),
value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1)) value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1))
proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed })
check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次',
validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder)
} }
proof.currentStage = 'RECEIPT_REPLAY'
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
// Existing receipt helper replays the exact original save command before each signature observation. // Existing receipt helper replays the exact original save command before each signature observation.
// Re-saving completed receipts with a fresh business key is also a no-transition operation. // Re-saving completed receipts with a fresh business key is also a no-transition operation.
for (const [index,signed] of proof.signatures.entries()) { for (const [index,signed] of proof.signatures.entries()) {
@@ -320,6 +398,7 @@ export async function run(context) {
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
const reader = context.context?.f40ReadOnlyOutboxEvidence const reader = context.context?.f40ReadOnlyOutboxEvidence
if (typeof reader === 'function') { if (typeof reader === 'function') {
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId], proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
@@ -336,7 +415,15 @@ export async function run(context) {
} }
} catch (error) { } catch (error) {
proof.finalStatus = 'FAILED' proof.finalStatus = 'FAILED'
proof.failedStage = proof.currentStage
throw error throw error
} finally { await disablePicker(scope,picker,7) } } finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
if (proof.finalStatus === 'VERIFIED') {
const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false)
if (failedSteps.length) proof.finalStatus = 'FAILED'
check(context,'F40 正式链、重放及临时PDA停用所有检查均通过', failedSteps.length === 0,
`failed=${failedSteps.length}`)
}
proof.currentStage = 'FINISHED'
return proof return proof
} }
+11 -4
View File
@@ -57,6 +57,13 @@ function sqlStrings(values) {
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',') return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',')
} }
export function parseMetadataRows(output) {
return output.trim().split(/\r?\n/).filter(Boolean).map((line) => {
if (!/^(?:[0-9a-f]{2})+$/i.test(line)) throw new Error('数据库元数据输出不是有效 HEX JSON')
return JSON.parse(Buffer.from(line, 'hex').toString('utf8'))
})
}
function runMysql(config, sql) { function runMysql(config, sql) {
const executable = process.env.MYSQL_BIN || 'mysql' const executable = process.env.MYSQL_BIN || 'mysql'
const result = spawnSync(executable, [ const result = spawnSync(executable, [
@@ -70,14 +77,14 @@ function runMysql(config, sql) {
}) })
if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`) if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`)
if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`) if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`)
return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) return parseMetadataRows(result.stdout)
} }
function snapshot(config, schemas) { function snapshot(config, schemas) {
const selected = sqlStrings(schemas) const selected = sqlStrings(schemas)
const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) const schemaRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`)
const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) const tableRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`)
const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) const columnRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`)
return { schemas: schemaRows, tables: tableRows, columns: columnRows } return { schemas: schemaRows, tables: tableRows, columns: columnRows }
} }
+14 -1
View File
@@ -1,7 +1,20 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { compareSnapshots } from './database-schema-diff.mjs' import { compareSnapshots, parseMetadataRows } from './database-schema-diff.mjs'
test('metadata transport preserves Unicode, escaped control characters and expression defaults', () => {
const rows = [{ schema: 'sample', table: 'orders', defaultValue: '国内\r\n海外\t"quote"\\path', generation: "concat('x', '\\n')" }, { defaultValue: null }]
const output = rows.map(row => Buffer.from(JSON.stringify(row), 'utf8').toString('hex')).join('\r\n') + '\r\n'
assert.deepEqual(parseMetadataRows(output), rows)
assert.deepEqual(parseMetadataRows(''), [])
})
test('metadata transport rejects invalid or truncated records', () => {
assert.throws(() => parseMetadataRows('abc'), /HEX JSON/)
assert.throws(() => parseMetadataRows('not-json'), /HEX JSON/)
assert.throws(() => parseMetadataRows(Buffer.from('{"schema":').toString('hex')), SyntaxError)
})
test('database schema diff ignores column order but catches contract changes', () => { test('database schema diff ignores column order but catches contract changes', () => {
const left = { const left = {
@@ -6,6 +6,15 @@ import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin
const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8')
test('F40 optional chain always creates its customer and rejects absent domestic PDA credentials', () => {
const options = parseArguments(['--modules', 'phase5-root-fulfillment',
'--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password',
'--merchant-username', 'prior-client', '--merchant-password', 'prior-password'])
assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment'])
options.pdaUsername = ''; options.pdaPassword = ''
assert.throws(() => validateExecutionOptions(options), /PDA/)
})
test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => {
for (const module of [ for (const module of [
'phase4-overseas-warehouse', 'phase4-overseas-warehouse',