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
+2
View File
@@ -4,6 +4,8 @@
总入口是 `../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` 报告,失败先核对报告再重跑,不自动清理。
普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
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 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')
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 SCENARIO = 'mojia-carton-measurement'
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'
}
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) {
const cacheKey = asId(warehouseId)
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',
`/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,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
const submissionReplay = await verifyMeasurementSubmitReplay(context, replayInput, submissionPayload)
const rejectedScanResponse = await multipartRequest(context, scanPath, {
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)
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,
}, { '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,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
@@ -1107,6 +1219,8 @@ export async function run(context) {
&& 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)
const approvalReplay = await verifyMeasurementApprovalReplay(context, replayInput, approvalPayload, approvalKey)
let sealedCartonMarks = []
let sealedSourcePalletNos = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
@@ -1156,6 +1270,7 @@ export async function run(context) {
OPTIONS)
context.createdResources.inboundMeasurement = {
replayEvidence: { submission: submissionReplay, approval: approvalReplay },
sealedCartonMarks, sealedSourcePalletNos,
marker: context.marker, tenantId, inboundId,
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { approvedInboundReady } from './orders.mjs'
import { approvedInboundReady, verifyOrderApprovalReplay } from './orders.mjs'
const tenantId = '10001'
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, /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?.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) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
@@ -742,6 +808,9 @@ export async function run(context) {
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 editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
@@ -4,7 +4,36 @@ import path from 'node:path'
import test from 'node:test'
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', () => {
assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/)
@@ -14,6 +14,27 @@ export function uniqueContainerProducer(item, index, all) {
return nonBlank(item?.containerNo)
&& 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 code(response) { return String(response?.body?.code ?? '') }
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)
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerMark = allProducers
.filter((item) => id(item?.containerNo) !== id(current.containerNo))
.flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark])
.map(id).find(nonBlank)
if (crossContainerMark) {
const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
if (crossContainerProbe) {
context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true,
JSON.stringify(crossContainerProbe),OPTIONS)
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: crossContainerMark }, cartonResolveRejected)
{ cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected)
} else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
}
@@ -1,5 +1,5 @@
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'
import assert from 'node:assert/strict'
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, /inboundCode: inbound\.inboundCode/)
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.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
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)
}
})
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 }
}
// 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.
export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) {
if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode)
@@ -1012,19 +1108,8 @@ export async function run(context) {
blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result)
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
const customerCompletedOrder = await merchant(context, merchantToken, 'GET', '客户读取签收后的统一订单最终状态',
`/api/merchant/v1/orders/${encodeURIComponent(sourceOrderId)}`)
context.assert(MODULE, '客户本企业订单最终状态为COMPLETED', customerCompletedOrder.response,
customerCompletedOrder.data?.summary?.mainStatus === 'COMPLETED'
&& id(customerCompletedOrder.data?.summary?.orderId) === sourceOrderId,
trace(customerCompletedOrder.response), OPTIONS)
await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|| await createSignedDocumentIsolationCustomer(context)
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 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) {
return {
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}`)
const current = await api(context, 'GET', '回查该片真实入仓来源',
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 任务',
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'
&& task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1,
`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,
destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId),
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,
}
context.createdResources.phase3Loading = loading
@@ -167,14 +206,15 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
planPalletIds: matches.map(row => id(row.planPalletId)),
})
const dispatchNo = dispatch.dispatchNo
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, {
const vehicle = {
vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: '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', '客户正式创建该片派送单',
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 : []
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 })
@@ -200,15 +240,29 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index)
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,
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. */
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 = {
marker: context.marker, rootOrderId: null, childOrderId: null, customerId: 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',
}
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
@@ -219,11 +273,26 @@ export async function run(context) {
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
phase4OverseasPdaOperations: { completedFixtures: [] },
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
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)
proof.currentStage = 'ORDER_CREATION'
await stage(scope, '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')
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
proof.rootOrderId = rootOrderId; proof.customerId = customerId
@@ -231,8 +300,10 @@ export async function run(context) {
query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId }))
check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT'
&& Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`)
proof.currentStage = 'ROOT_LOAD_AND_SHIP'
const first = await fragment(scope, rootInbound, rootOrderId, 0)
proof.fragments.push(first)
proof.currentStage = 'REMAINDER_IDENTITY'
const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || []
@@ -253,8 +324,10 @@ export async function run(context) {
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
const second = await fragment(scope, childInbound, rootOrderId, 1)
proof.fragments.push(second)
proof.currentStage = 'FULFILLMENT_TREE'
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
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'
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
scope.marker = `${scope.context.f40BaseMarker}-PDA`
const picker = await createPicker(scope, 7, warehouse.id, access)
try {
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
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')
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] }
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
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 convertReceivingContainers(scope,[first,second]), 'two-containers-converted')
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second]
for (const [index, producer] of signatureSources.entries()) {
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker
scope.createdResources.phase4OverseasPda.marker = scope.marker
@@ -295,15 +367,21 @@ export async function run(context) {
&& scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row =>
id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo),
`container=${producer.containerNo}`)
proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}`
const signed = await sendFragment(scope,producer,picker,login.accessToken,index)
proof.signatures.push(signed)
proof.currentStage = `COMPLETION_GATE_${index+1}`
const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId),
value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1))
proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed })
check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次',
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 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.
// Re-saving completed receipts with a fresh business key is also a no-transition operation.
for (const [index,signed] of proof.signatures.entries()) {
@@ -320,6 +398,7 @@ export async function run(context) {
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
const reader = context.context?.f40ReadOnlyOutboxEvidence
if (typeof reader === 'function') {
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
@@ -336,7 +415,15 @@ export async function run(context) {
}
} catch (error) {
proof.finalStatus = 'FAILED'
proof.failedStage = proof.currentStage
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
}