From 22584e7fdece2868f03191f95a740bb88d112872 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 15:16:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-acceptance.mjs | 6 +- scripts/admin-test-env-modules/README.md | 2 + ...und-measurement-box-code-contract.test.mjs | 131 +++++++++++- .../inbound-measurement.mjs | 121 ++++++++++- ...ers-approval-convergence-contract.test.mjs | 75 ++++++- scripts/admin-test-env-modules/orders.mjs | 69 ++++++ .../phase4-overseas-pda-contract.test.mjs | 31 ++- .../phase4-overseas-pda.mjs | 32 ++- ...se5-overseas-fulfillment-contract.test.mjs | 111 +++++++++- .../phase5-overseas-fulfillment.mjs | 109 ++++++++-- .../phase5-root-fulfillment-contract.test.mjs | 202 ++++++++++++++++++ .../phase5-root-fulfillment.mjs | 111 ++++++++-- scripts/database-schema-diff.mjs | 15 +- scripts/database-schema-diff.test.mjs | 15 +- ...verseas-acceptance-entry-contract.test.mjs | 9 + 15 files changed, 994 insertions(+), 45 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 252af883c..dcf0b8e87 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url' 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 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-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', @@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['phase5-root-fulfillment', ['merchant-qualification-fixture']], ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['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 merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' const requiresMarkerOwnedMerchantFixture = [ + 'phase5-root-fulfillment', 'phase4-overseas-business', 'phase4-overseas-return-receipt', '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 ('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())) { throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)') } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index cc8c75213..a68a7ae97 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 8ed2a6bf6..14c7fbcb1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -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'/) +}) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index a5f849664..319527697 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -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), diff --git a/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs index 400228596..4a3d3e16c 100644 --- a/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-approval-convergence-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index dd2f7bc54..a48979c7e 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 89392a80b..6bce91243 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 35a3a9994..55a13985d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index fa5f6287d..25b7b3f51 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 5110e5a11..f136606d2 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs new file mode 100644 index 000000000..d237ea7b3 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 1b7848ab2..829071171 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -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 } diff --git a/scripts/database-schema-diff.mjs b/scripts/database-schema-diff.mjs index 243b8185a..efb92509f 100644 --- a/scripts/database-schema-diff.mjs +++ b/scripts/database-schema-diff.mjs @@ -57,6 +57,13 @@ function sqlStrings(values) { 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) { const executable = process.env.MYSQL_BIN || 'mysql' 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.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) { 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 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 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 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 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 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 } } diff --git a/scripts/database-schema-diff.test.mjs b/scripts/database-schema-diff.test.mjs index 91cd6c281..0aa8c0527 100644 --- a/scripts/database-schema-diff.test.mjs +++ b/scripts/database-schema-diff.test.mjs @@ -1,7 +1,20 @@ import assert from 'node:assert/strict' 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', () => { const left = { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index fdb4551bc..5a1e18355 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -6,6 +6,15 @@ import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin 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', () => { for (const module of [ 'phase4-overseas-warehouse',