From e4f65dae5f643f7a104354d430f8214bc692baf0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 12:35:29 +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 --- .../finance-full-fixtures-contract.test.mjs | 21 +++ .../finance-full-fixtures.mjs | 99 +++++++++++- ...ance-reconciliation-full-contract.test.mjs | 12 +- .../finance-reconciliation-full.mjs | 49 +++--- .../phase3-evidence-gate.mjs | 8 + .../phase3-loading-finalize.mjs | 94 ++++++++++++ .../admin-test-env-modules/phase3-loading.mjs | 141 +++++++++++++++++- .../phase3-producer-chain-contract.test.mjs | 51 +++++++ 8 files changed, 434 insertions(+), 41 deletions(-) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index f960634ed..101a5e656 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -102,6 +102,27 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2) }) +test('run preserves real price-row profiles when creating automatic orders', async () => { + const context = fakeContext() + const profiles = [ + { versionId: 'PRICE-1', rateId: 'RATE-KG', chargeUnit: 'KG', originWarehouseId: '11', channelCode: 'SEA-KG', pricingTaxMode: 'BUY_DECLARATION', destinationCode: 'USLAX' }, + { versionId: 'PRICE-1', rateId: 'RATE-CBM', chargeUnit: 'CBM', originWarehouseId: '11', channelCode: 'SEA-CBM', pricingTaxMode: 'BUY_DECLARATION', destinationCode: 'USLAX' }, + ] + const seen = [] + context.financeFixtureHooks = { + ...hookSet(), + loadPricingEvidence: async () => ({ activeVersionIds: ['PRICE-1'], profiles }), + createOrder: async (ctx, suffix, profile) => { + seen.push(profile) + return { marker: `${ctx.marker}-${suffix}`, tenantId: '701', orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, inboundId: `I-${suffix}` } + }, + } + await run(context) + assert.equal(seen[0].chargeUnit, 'KG') + assert.equal(seen[1].chargeUnit, 'CBM') + assert.deepEqual(context.createdResources.financeFullFixtures.pricingEvidence.profiles, profiles) +}) + test('run behavior preserves null IDs and deployment blockers when supplier, voucher and container are unavailable', async () => { const context = fakeContext() context.financeFixtureHooks = hookSet({ missing: true }) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 148516d54..9e31a7593 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -9,17 +9,50 @@ function rows(value) { return Array.isArray(value) ? value : Array.isArray(value function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) } function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) } -async function createOrder(context, suffix) { - if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix) +async function createOrder(context, suffix, pricingProfile = null) { + if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile) const originalMarker = context.marker const original = context.createdResources.orders + const originalFetch = globalThis.fetch context.marker = `${originalMarker}-${suffix}` + if (pricingProfile && typeof originalFetch === 'function') { + // The orders module discovers candidates independently. Reorder only those + // real responses so its payload uses one exact active price-book row. + globalThis.fetch = async (input, init = {}) => { + const response = await originalFetch(input, init) + const url = String(input) + if ((init.method || 'GET') === 'GET' && (url.includes('/api/merchant/v1/warehouses?') + || url.includes('/api/merchant/v1/first-leg/pricing/channels?'))) { + const body = await response.clone().json().catch(() => null) + const data = body?.code === '200' ? body.data : null + if (url.includes('warehouseType=FBA')) { + const records = Array.isArray(data?.records) ? data.records : [] + const match = records.find((item) => id(item.warehouseCode || item.code) === id(pricingProfile.destinationCode)) + if (match) body.data = { ...data, records: [match, ...records.filter((item) => item !== match)] } + } else if (url.includes('warehouseType=DOMESTIC')) { + const records = Array.isArray(data?.records) ? data.records : [] + const match = records.find((item) => id(item.id) === id(pricingProfile.originWarehouseId)) + if (match) body.data = { ...data, records: [match, ...records.filter((item) => item !== match)] } + } else { + const channels = Array.isArray(data) ? data : [] + const match = channels.find((item) => id(item.channelCode) === id(pricingProfile.channelCode)) + if (match) body.data = [{ ...match, pricingTaxModes: [pricingProfile.pricingTaxMode, + ...(match.pricingTaxModes || []).filter((mode) => mode !== pricingProfile.pricingTaxMode)] }, + ...channels.filter((item) => item !== match)] + } + return new Response(JSON.stringify(body), { status: response.status, statusText: response.statusText, headers: response.headers }) + } + return response + } + } try { const orders = await import(new URL('./orders.mjs', import.meta.url)) await orders.run(context) const created = context.createdResources.orders - return created?.marker === context.marker && created?.orderId && created?.inboundId ? { ...created } : null + return created?.marker === context.marker && created?.orderId && created?.inboundId + ? { ...created, pricingProfile } : null } finally { + globalThis.fetch = originalFetch context.marker = originalMarker if (original) context.createdResources.orders = original else delete context.createdResources.orders @@ -169,7 +202,55 @@ async function loadPricingEvidence(context) { if (context.financeFixtureHooks?.loadPricingEvidence) return context.financeFixtureHooks.loadPricingEvidence(context) const response = await context.request('GET', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=ACTIVE') const versions = context.apiSuccess(MODULE, '读取真实生效价格配置', response, '', OPTIONS) - return { activeVersionIds: rows(versions).map((item) => id(item.id)).filter(Boolean) } + const active = rows(versions).filter((item) => item?.status === 'ACTIVE') + const profiles = [] + for (const version of active) { + const detailResponse = await context.request('GET', `/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(version.id)}`) + const detail = context.apiSuccess(MODULE, '读取 ACTIVE 价格簿真实费率行', detailResponse, `versionId=${version.id}`, OPTIONS) + const rates = Array.isArray(detail?.rates) ? detail.rates : [] + for (const rate of rates) { + if (rate?.destinationType !== 'FBA' || !rate?.originWarehouseId || !rate?.channelCode || !rate?.pricingTaxMode || !rate?.destinationCode) continue + if (!['KG', 'CBM'].includes(rate.chargeUnit) || profiles.some((item) => item.chargeUnit === rate.chargeUnit)) continue + const matches = rates.filter((item) => id(item.originWarehouseId) === id(rate.originWarehouseId) + && item.channelCode === rate.channelCode && item.destinationType === rate.destinationType + && String(item.destinationCode).toUpperCase() === String(rate.destinationCode).toUpperCase() + && item.pricingTaxMode === rate.pricingTaxMode) + if (matches.length !== 1) continue + profiles.push({ versionId: id(version.id), rateId: id(rate.id), chargeUnit: rate.chargeUnit, + originWarehouseId: id(rate.originWarehouseId), channelCode: rate.channelCode, + pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode }) + } + } + context.assert(MODULE, 'ACTIVE 价格簿包含 KG 与 CBM 可用真实组合', response, + profiles.some((item) => item.chargeUnit === 'KG') && profiles.some((item) => item.chargeUnit === 'CBM'), + `units=${profiles.map((item) => item.chargeUnit).join(',') || 'none'}`, OPTIONS) + return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles } +} + +async function createMarkerContainer(context, resource) { + if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource) + if (context.financeFixtureHooks) return null + const original = context.createdResources.phase3ContainerLoading + const originalMarker = context.marker + const snapshots = ['orders', 'inboundMeasurement'].map((key) => [key, + { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]) + context.createdResources.orders = resource + context.createdResources.inboundMeasurement = resource + context.marker = resource.marker || originalMarker + try { + const module = await import(new URL('./phase3-container-loading.mjs', import.meta.url)) + await module.run(context) + const created = context.createdResources.phase3ContainerLoading + return created?.marker === context.marker && created?.containerBookingId && created?.loadingPlanId ? { ...created } : null + } finally { + context.marker = originalMarker + for (const [key, snapshot] of snapshots) { + if (snapshot.present) context.createdResources[key] = snapshot.value + else delete context.createdResources[key] + } + if (original) context.createdResources.phase3ContainerLoading = original + else delete context.createdResources.phase3ContainerLoading + } } async function createContainerCost(context, customerId, container, voucherFileId) { @@ -186,8 +267,9 @@ export async function run(context) { const start = context.steps.length const pricingEvidence = await loadPricingEvidence(context) const resources = [] - for (const suffix of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL']) { - const resource = await createOrder(context, suffix) + const profiles = pricingEvidence.profiles || [] + for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL'].entries()) { + const resource = await createOrder(context, suffix, profiles[index % profiles.length] || null) context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource), resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS) if (resource) resources.push(resource) @@ -236,8 +318,9 @@ export async function run(context) { } } - const container = context.createdResources?.phase3ContainerLoading - const containerOwned = String(container?.marker || '').startsWith(`${context.marker}-`) + const container = await createMarkerContainer(context, measured[0]) + || context.createdResources?.phase3ContainerLoading + const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`)) && container?.loadingPlanId && container?.containerBookingId let containerFixture = null if (containerOwned && feeItems.PAYABLE && supplier?.id) { diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 19003697c..385dc1794 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -16,7 +16,7 @@ function fakeContext(withFixture = true) { const marker = 'FIN-TEST-MARKER' const receivables = [ { feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' }, - { feeSourceId: '102', orderId: '202', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' }, + { feeSourceId: '102', orderId: '202', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' }, { feeSourceId: '103', orderId: '203', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' }, { feeSourceId: '104', orderId: '204', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' }, ] @@ -124,10 +124,12 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输 assert.match(source, /generationMode === 'SELECTED'/) assert.match(source, /businessDates\.size < 2/) assert.match(source, /receivables\.length < 4/) - assert.match(source, /const groups = \[\[receivables\[0\], receivables\[1\]\]/) + assert.match(source, /const groups = \[cutoffSources/) assert.match(source, /\/payable-settlements/) assert.match(source, /fixture\.root\.statements = statementRows/) assert.match(source, /fixture\.root\.receivableSettlements = arRows/) + assert.doesNotMatch(source, /generationMode:\s*['"]BY_CUTOFF['"]/) + assert.doesNotMatch(source, /\/fee-pool/) assert.match(source, /http: response\?\.http \?\? null/) assert.match(source, /requestId: response\?\.requestId \?\? null/) assert.match(source, /traceId: response\?\.traceId \?\? null/) @@ -141,8 +143,8 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 await run(context) const fixture = context.createdResources.financeFullFixtures assert.equal(fixture.statements.length, 4) - assert.equal(fixture.statements[0].kind, 'BY_CUTOFF') - assert.equal(fixture.statements[0].status, 'VOIDED') + assert.equal(fixture.statements[0].kind, 'SELECTED_DATE') + assert.equal(fixture.statements[0].status, 'ISSUED') assert.deepEqual(fixture.statements.slice(1).map((item) => item.status), ['ISSUED', 'ISSUED', 'ISSUED']) assert.equal(fixture.receivableSettlements.length, 3) assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3) @@ -150,6 +152,8 @@ test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 assert.equal(fixture.payableSettlements[0].supplierId, '501') assert.equal(fixture.payableSettlements[0].partnerId, '501') assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner') + assert.equal(context.calls.some((call) => call.path.includes('/fee-pool') + || call.body?.generationMode === 'BY_CUTOFF'), false) assert.equal(context.steps.filter((step) => !step.passed).length, 0) }) diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 721fab79c..bd545dbdf 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -223,29 +223,25 @@ export async function run(context) { const cutoffDate = [...businessDates].sort()[0] const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate) - const poolResponse = await context.request('GET', - `${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=RECEIVABLE`) - const pool = context.apiSuccess(MODULE, 'BY_CUTOFF 前核对客户应收费用池', poolResponse, '', OPTIONS) - const eligible = rows(pool).filter((source) => sourceDate(source) === cutoffDate - && String(source.currency ?? source.currencyCode) === 'CNY' && source.reviewStatus === 'APPROVED' - && source.settlementIncluded === true && source.sourceStatus === 'AVAILABLE').map(sourceId) - if (!sameIds(eligible, cutoffSources.map((source) => source.feeSourceId))) { - blocker(context, 'BY_CUTOFF 历史来源隔离门禁', - '指定日期全部可结算来源必须与本轮 fixture 精确相等,禁止锁定历史来源', 'unsafe-cutoff-source-set', poolResponse) + if (cutoffSources.length < 2) { + blocker(context, '日期筛选多行来源门禁', + `最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`, + 'insufficient-same-date-receivable-sources') finish(context, start) return } - const cutoff = await createStatement(context, cutoffSources, 'cutoff', '按真实业务日期生成对账单', 'BY_CUTOFF') - let cutoffStatement = cutoff.statement - context.assert(MODULE, 'BY_CUTOFF 仅纳入真实日期范围内本轮来源', cutoff.response, - cutoffStatement && cutoff.body.periodStart === cutoffDate && cutoff.body.periodEnd === cutoffDate - && sameIds(lineSourceIds(cutoffStatement), cutoffSources.map((source) => source.feeSourceId)), - `date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(cutoffStatement).length}`, OPTIONS) - // ReconciliationStatementService explicitly permits VOID from DRAFT and releases LOCKED sources. - cutoffStatement = await act(context, cutoffStatement, 'VOID', 'cutoff-draft-void', `${context.marker}-release-cutoff`) - if (!cutoffStatement) { finish(context, start); return } - - const groups = [[receivables[0], receivables[1]], [receivables[2]], receivables.slice(3)] + // Date coverage intentionally uses SELECTED. BY_CUTOFF queries all eligible customer history. + const remainingSources = receivables.filter((source) => source.businessDate !== cutoffDate) + if (remainingSources.length < 2) { + blocker(context, '跨日期三组来源门禁', + `最早 businessDate=${cutoffDate} 之外仅有 ${remainingSources.length} 条本轮来源,无法生成三张非空独立对账单`, + 'insufficient-cross-date-receivable-sources') + finish(context, start) + return + } + const groups = [cutoffSources, ...(() => { + return [remainingSources.slice(0, 1), remainingSources.slice(1)] + })()] const issuedStatements = [] for (let index = 0; index < groups.length; index += 1) { const created = await createStatement(context, groups[index], `selected-${index + 1}`, @@ -262,6 +258,15 @@ export async function run(context) { return } issuedStatements.push(issued) + if (index === 0) { + context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response, + created.body.periodStart === cutoffDate && created.body.periodEnd === cutoffDate + && sameIds(lineSourceIds(created.statement), cutoffSources.map((source) => source.feeSourceId)) + && lineSourceIds(created.statement).every((source) => !receivables + .filter((item) => item.businessDate !== cutoffDate) + .some((item) => item.feeSourceId === source)), + `date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS) + } } const receivableSettlements = [] @@ -298,8 +303,8 @@ export async function run(context) { } const statementRows = [ - { kind: 'BY_CUTOFF', statementId: statementId(cutoffStatement), status: cutoffStatement.status, - version: cutoffStatement.version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId), + { kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status, + version: issuedStatements[0].version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId), periodStart: cutoffDate, periodEnd: cutoffDate }, ...issuedStatements.map((statement, index) => ({ kind: index === 0 ? 'SELECTED_FULL_CHAIN' : 'SELECTED', statementId: statementId(statement), status: statement.status, version: statement.version, diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index e3498c783..56b99c3f7 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -17,6 +17,7 @@ export async function run(context) { const required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const active = evidences.filter((item) => item.status !== 'VOID') const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId)) + const missingTypeVoided = evidences.find((item) => id(item.id) === id(fixture.missingTypeVoidedEvidenceId)) context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response, required.every((type) => active.some((item) => item.evidenceType === type)) && evidences.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 2 @@ -29,4 +30,11 @@ export async function run(context) { && Boolean(voided.voidedBy) && Boolean(voided.voidedAt) && !active.some((item) => id(item.id) === id(fixture.voidedEvidenceId)), `voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS) + context.assert(MODULE, '缺类作废审计保留且补传证据恢复有效门禁', response, + Boolean(missingTypeVoided) && missingTypeVoided.status === 'VOID' + && missingTypeVoided.voidReason === `${context.marker}-temporary-missing-type` + && Boolean(missingTypeVoided.voidedBy) && Boolean(missingTypeVoided.voidedAt) + && active.some((item) => item.evidenceType === 'PHOTO_STEP_4' + && id(item.fileId) === id(fixture.replacementEvidenceFileId)), + `voidedEvidenceId=${id(fixture.missingTypeVoidedEvidenceId) || 'missing'}, replacementFileId=${id(fixture.replacementEvidenceFileId) || 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index f8d157d69..4101099fa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -3,6 +3,13 @@ const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlock function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function evidenceSignature(data) { + return JSON.stringify((data?.evidences || []).map((item) => ({ + id: id(item?.id), type: item?.evidenceType || null, fileId: id(item?.fileId), + status: item?.status || null, version: Number(item?.version), sealNo: item?.sealNo || null, + voidReason: item?.voidReason || null, voidedBy: id(item?.voidedBy), voidedAt: item?.voidedAt || null, + }))) +} export function buildFinalizeResource(fixture, finalData, completeResponse) { const observedStatus = finalData?.summary?.processStatus || fixture?.inboundStatus || 'UNKNOWN' @@ -86,5 +93,92 @@ export async function run(context) { && item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'), `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS) + const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}` + const immutableEvidenceResponse = await context.request('GET', evidencePath) + const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS) + const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(id(fixture.loadingPlanId))}` + const immutablePlanResponse = await context.request('GET', planPath) + const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS) + const finalVersion = Number(finalData?.summary?.version) + const historySignature = JSON.stringify((finalData?.statusHistory || []).map((item) => ({ + action: item?.action || null, from: item?.fromStatus || null, to: item?.toStatus || null, + operatorId: id(item?.operatorId), occurredAt: item?.occurredAt || item?.createdAt || null, + }))) + const immutableEvidenceSignature = evidenceSignature(immutableEvidence) + const immutablePlanSignature = JSON.stringify({ + version: Number(immutablePlan?.version), status: immutablePlan?.status || null, + containerNo: immutablePlan?.containerNo || null, voyageNo: immutablePlan?.voyageNo || null, + items: (immutablePlan?.items || []).map((item) => ({ id: id(item?.id), frozenAt: item?.frozenAt || null })), + }) + + const completeNewKey = await request(context, 'POST', + `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: finalVersion, remark: `${context.marker}-loaded-new-key` }, + { 'Idempotency-Key': `${context.marker}-p3-complete-loaded-new-key`.slice(0, 128) }) + context.assert(MODULE, 'LOADED 后使用新键重复 complete 幂等保持终态', completeNewKey, + completeNewKey.http === 200 && codeOf(completeNewKey) === '200' + && completeNewKey.body?.data?.mainStatus === 'LOADED', + `HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.mainStatus || 'missing'}`, OPTIONS) + + const completeSameKeyDifferentBody = await request(context, 'POST', + `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: version, remark: `${context.marker}-different-complete-body` }, + { 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) }) + context.addStep(MODULE, 'LOADED complete 同键异参返回冲突', completeSameKeyDifferentBody, + completeSameKeyDifferentBody.http === 200 && codeOf(completeSameKeyDifferentBody) === 'A0409' + && Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId), + `HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) + + const postLoadedEvidence = await request(context, 'POST', evidencePath, { + expectedVersion: 0, evidenceType: 'PHOTO_STEP_2', fileId: fixture.postLoadedFileId, + fileName: 'client-must-not-override.png', sealNo: null, remark: context.marker, + }, { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) }) + context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence, + postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409' + && Boolean(postLoadedEvidence.requestId) && Boolean(postLoadedEvidence.traceId), + `HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS) + + const postLoadedVoid = await request(context, 'POST', + `${base}/loading/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: Number(fixture.immutableEvidenceVersion), reason: `${context.marker}-void-after-loaded` }, + { 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) }) + context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid, + postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409' + && Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId), + `HTTP=${postLoadedVoid.http}, code=${codeOf(postLoadedVoid)}, requestId=${postLoadedVoid.requestId || 'missing'}, traceId=${postLoadedVoid.traceId || 'missing'}`, OPTIONS) + + const immutableTransport = await request(context, 'PATCH', + `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`, + { containerNo: `MUTATE-${context.marker}`.slice(0, 64), voyageNo: 'MUTATE', + expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` }, + { 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) }) + context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport, + immutableTransport.http === 200 && codeOf(immutableTransport) === 'A0409' + && Boolean(immutableTransport.requestId) && Boolean(immutableTransport.traceId), + `HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS) + + const afterBoundaryResponse = await context.request('GET', detailPath) + const afterBoundary = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的入库快照', afterBoundaryResponse, '', OPTIONS) + const afterEvidenceResponse = await context.request('GET', evidencePath) + const afterEvidence = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的证据快照', afterEvidenceResponse, '', OPTIONS) + const afterPlanResponse = await context.request('GET', planPath) + const afterPlan = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的计划快照', afterPlanResponse, '', OPTIONS) + const afterHistorySignature = JSON.stringify((afterBoundary?.statusHistory || []).map((item) => ({ + action: item?.action || null, from: item?.fromStatus || null, to: item?.toStatus || null, + operatorId: id(item?.operatorId), occurredAt: item?.occurredAt || item?.createdAt || null, + }))) + const afterPlanSignature = JSON.stringify({ + version: Number(afterPlan?.version), status: afterPlan?.status || null, + containerNo: afterPlan?.containerNo || null, voyageNo: afterPlan?.voyageNo || null, + items: (afterPlan?.items || []).map((item) => ({ id: id(item?.id), frozenAt: item?.frozenAt || null })), + }) + context.assert(MODULE, 'LOADED 后拒绝与幂等请求保持状态、版本、证据、计划和审计零漂移', afterBoundaryResponse, + afterBoundary?.summary?.processStatus === 'LOADED' + && Number(afterBoundary?.summary?.version) === finalVersion + && afterHistorySignature === historySignature + && evidenceSignature(afterEvidence) === immutableEvidenceSignature + && afterPlanSignature === immutablePlanSignature, + `status=${afterBoundary?.summary?.processStatus || 'missing'}, version=${afterBoundary?.summary?.version ?? 'missing'}, histories=${afterBoundary?.statusHistory?.length ?? 'n/a'}, evidences=${afterEvidence?.evidences?.length ?? 'n/a'}, planVersion=${afterPlan?.version ?? 'missing'}`, OPTIONS) + context.createdResources.phase3Loading = buildFinalizeResource(fixture, finalData, completeResponse) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index ecdd4b12b..36b90b28b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -278,6 +278,13 @@ export async function run(context) { staleBatch.http === 200 && codeOf(staleBatch) === 'A0409', `HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}`, OPTIONS) + const beforeInvalidTaskResponse = await context.request('GET', + `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const beforeInvalidTask = context.apiSuccess(MODULE, '非法混合批次前读取 PDA 全量任务基线', + beforeInvalidTaskResponse, '', OPTIONS) + const beforeInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const beforeInvalidOperations = context.apiSuccess(MODULE, '非法混合批次前读取 PDA 操作历史基线', + beforeInvalidOperationsResponse, '', OPTIONS) const invalidBatch = await request(context, 'POST', batchPath, { ...batchPayload, items: [...batchPayload.items, { @@ -290,13 +297,19 @@ export async function run(context) { const unchangedAfterInvalidResponse = await context.request('GET', `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) const unchangedAfterInvalid = context.apiSuccess(MODULE, '回查失败批次后的 PDA 任务快照', unchangedAfterInvalidResponse, '', OPTIONS) - const unchangedItem = unchangedAfterInvalid?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) - context.assert(MODULE, '失败批次整批回滚且无明细部分写入', unchangedAfterInvalidResponse, - unchangedAfterInvalid?.taskStatus === 'LOADING' - && Number(unchangedAfterInvalid?.version) === Number(batchPayload.expectedVersion) - && unchangedItem?.itemStatus === 'PENDING' && Number(unchangedItem?.pickedPackageCount) === 0 - && Number(unchangedItem?.version) === Number(canonicalItem.version), - `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, itemStatus=${unchangedItem?.itemStatus || 'missing'}, picked=${unchangedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS) + const afterInvalidOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const afterInvalidOperations = context.apiSuccess(MODULE, '非法混合批次后回查 PDA 操作历史', + afterInvalidOperationsResponse, '', OPTIONS) + const itemSignature = (task) => JSON.stringify((task?.items || []).map((item) => ({ + id: id(item?.id), status: item?.itemStatus || null, picked: Number(item?.pickedPackageCount), + version: Number(item?.version), + }))) + context.assert(MODULE, '合法明细加非法明细失败后任务、全部明细和操作历史零写入', unchangedAfterInvalidResponse, + unchangedAfterInvalid?.taskStatus === beforeInvalidTask?.taskStatus + && Number(unchangedAfterInvalid?.version) === Number(beforeInvalidTask?.version) + && itemSignature(unchangedAfterInvalid) === itemSignature(beforeInvalidTask) + && operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations), + `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${itemSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) const merchantLogin = await context.request('POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, @@ -581,6 +594,23 @@ export async function run(context) { if (!evidenceItem?.id) return evidenceItems.push(evidenceItem) + if (evidenceType === 'PHOTO_STEP_1') { + const replay = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) + const replayData = context.apiSuccess(MODULE, '装柜证据绑定同键同参幂等重放', replay, '', OPTIONS) + const replayItems = replayData?.evidences?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] + context.assert(MODULE, '证据绑定重放返回同一记录且不重复追加', replay, + replayItems.length === 1 && id(replayItems[0]?.id) === id(evidenceItem.id), + `evidenceId=${id(replayItems[0]?.id) || 'missing'}, count=${replayItems.length}`, OPTIONS) + const conflict = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { + expectedVersion: 0, evidenceType, fileId, + fileName: 'client-must-not-override.png', sealNo: null, remark: `${context.marker}-different`, + }, { 'Idempotency-Key': `${context.marker}-p3-evidence-${evidenceType}`.slice(0, 128) }) + context.addStep(MODULE, '装柜证据绑定同键异参返回冲突', conflict, + conflict.http === 200 && codeOf(conflict) === 'A0409' + && Boolean(conflict.requestId) && Boolean(conflict.traceId), + `HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS) + } + const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`) context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download, download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0, @@ -642,6 +672,100 @@ export async function run(context) { && (item.status === 'VOID' || item.valid === false) && item.voidReason === `${context.marker}-wrong-photo`), `evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS) + const missingTypeTarget = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_4') + const missingTypePath = `${base}/loading/evidence/${encodeURIComponent(id(missingTypeTarget?.id))}/void?tenantId=${encodeURIComponent(tenantId)}` + const staleMissingTypeVoid = missingTypeTarget + ? await request(context, 'POST', missingTypePath, + { expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }, + { 'Idempotency-Key': `${context.marker}-p3-evidence-void-stale`.slice(0, 128) }) + : voidResponse + context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid, + Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409', + `HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS) + + const missingTypeVoidKey = `${context.marker}-p3-evidence-void-missing-type`.slice(0, 128) + const missingTypeVoidBody = { + expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`, + } + const missingTypeVoid = missingTypeTarget + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, + { 'Idempotency-Key': missingTypeVoidKey }) + : voidResponse + const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS) + context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid, + Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.evidences?.some?.((item) => + id(item.id) === id(missingTypeTarget.id) && item.status === 'VOID'), + `evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS) + + const missingTypeVoidReplay = missingTypeTarget + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, + { 'Idempotency-Key': missingTypeVoidKey }) + : voidResponse + context.assert(MODULE, '证据作废同键同参重放保持同一 VOID 结果', missingTypeVoidReplay, + missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === '200' + && missingTypeVoidReplay.body?.data?.evidences?.evidences?.some?.((item) => + id(item.id) === id(missingTypeTarget?.id) && item.status === 'VOID'), + `HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS) + const missingTypeVoidConflict = missingTypeTarget + ? await request(context, 'POST', missingTypePath, + { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }, + { 'Idempotency-Key': missingTypeVoidKey }) + : voidResponse + context.addStep(MODULE, '证据作废同键异参返回冲突', missingTypeVoidConflict, + missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409', + `HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS) + const duplicateVoid = missingTypeTarget + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, + { 'Idempotency-Key': `${context.marker}-p3-evidence-void-duplicate`.slice(0, 128) }) + : voidResponse + context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid, + duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409', + `HTTP=${duplicateVoid.http}, code=${codeOf(duplicateVoid)}`, OPTIONS) + + const missingTypeComplete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: loadingVersion, remark: `${context.marker}-missing-photo-step-4` }, + { 'Idempotency-Key': `${context.marker}-p3-complete-missing-photo-step-4`.slice(0, 128) }) + context.addStep(MODULE, '作废唯一证据类型后 LOADED 门禁拒绝', missingTypeComplete, + missingTypeComplete.http === 200 && codeOf(missingTypeComplete) === 'A0409', + `HTTP=${missingTypeComplete.http}, code=${codeOf(missingTypeComplete)}`, OPTIONS) + + const replacementUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_4') + const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) + const replacementFileId = id(replacementFile?.fileId) + const replacementBind = replacementFileId + ? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_4', replacementFileId, suffix, 'PHOTO_STEP_4_RESTORE') + : replacementUpload + const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementBind, '', OPTIONS) + context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementBind, + Boolean(replacementFileId) + && replacementData?.evidences?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4' + && id(item.fileId) === replacementFileId && item.status !== 'VOID') + && replacementData?.evidences?.evidences?.some?.((item) => id(item.id) === id(missingTypeTarget?.id) + && item.status === 'VOID'), + `replacementFileId=${replacementFileId || 'missing'}`, OPTIONS) + + const customerEvidence = merchantToken + ? await request(context, 'GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, + undefined, { Authorization: `Bearer ${merchantToken}` }) + : merchantLogin + context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence, + customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403' + && Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId), + `HTTP=${customerEvidence.http}, code=${codeOf(customerEvidence)}, requestId=${customerEvidence.requestId || 'missing'}, traceId=${customerEvidence.traceId || 'missing'}`, OPTIONS) + const wrongTenantEvidence = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(Number(tenantId) + 1)}`) + context.addStep(MODULE, '错误客户作用域不可读取本轮装柜证据', wrongTenantEvidence, + wrongTenantEvidence.http === 200 && codeOf(wrongTenantEvidence) === 'A0404', + `HTTP=${wrongTenantEvidence.http}, code=${codeOf(wrongTenantEvidence)}`, OPTIONS) + const wrongInboundEvidence = await context.request('GET', + `/api/platform/v1/inbounds/999999999999999999/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + context.addStep(MODULE, '错误入库单不可读取本轮装柜证据', wrongInboundEvidence, + wrongInboundEvidence.http === 200 && codeOf(wrongInboundEvidence) === 'A0404', + `HTTP=${wrongInboundEvidence.http}, code=${codeOf(wrongInboundEvidence)}`, OPTIONS) + + const postLoadedUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_2') + const postLoadedFile = context.apiSuccess(MODULE, '预置 LOADED 后新增证据边界文件', postLoadedUpload, '', OPTIONS) + const postLoadedFileId = id(postLoadedFile?.fileId) + const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, @@ -666,6 +790,9 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)), voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, + missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, + immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), + postLoadedFileId, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index caa6d1937..91ad95d11 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -129,6 +129,19 @@ test('PDA completion acceptance verifies immutable BATCH audit with operator met assert.match(convergence, /item\.actionType === 'BATCH'/) }) +test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { + const loading = source('./phase3-loading.mjs') + assertOrder(loading, [ + '非法混合批次前读取 PDA 全量任务基线', + 'PDA 原子批次任一非法明细时整批拒绝', + '回查失败批次后的 PDA 任务快照', + '非法混合批次后回查 PDA 操作历史', + '合法明细加非法明细失败后任务、全部明细和操作历史零写入', + ]) + assert.match(loading, /itemSignature\(unchangedAfterInvalid\) === itemSignature\(beforeInvalidTask\)/) + assert.match(loading, /operationsSignature\(afterInvalidOperations\) === operationsSignature\(beforeInvalidOperations\)/) +}) + test('customs provisions released booking and completed truck for the selected loading container', () => { const value = source('./phase3-customs.mjs') assertOrder(value, [ @@ -199,3 +212,41 @@ test('customs and finalize consume the same loading inbound identity', () => { assert.equal(finalized.inboundId, loading.inboundId) assert.equal(finalized.finalStatus, 'LOADED') }) + +test('loading evidence acceptance covers idempotency, reversible missing-type gate and scope isolation', () => { + const loading = source('./phase3-loading.mjs') + assertOrder(loading, [ + '装柜证据绑定同键同参幂等重放', + '装柜证据绑定同键异参返回冲突', + '装柜证据作废旧版本返回冲突', + '临时作废唯一 PHOTO_STEP_4 证据', + '证据作废同键同参重放保持同一 VOID 结果', + '证据作废同键异参返回冲突', + '已作废证据使用新请求重复作废返回冲突', + '作废唯一证据类型后 LOADED 门禁拒绝', + '补传恢复 PHOTO_STEP_4 有效证据', + '客户身份无权访问平台装柜证据', + '错误客户作用域不可读取本轮装柜证据', + '错误入库单不可读取本轮装柜证据', + ]) + assert.match(loading, /missingTypeVoidedEvidenceId/) + assert.match(loading, /replacementEvidenceFileId/) +}) + +test('loaded boundary acceptance proves evidence and transport immutability with zero drift', () => { + const finalize = source('./phase3-loading-finalize.mjs') + assertOrder(finalize, [ + 'LOADED 后使用新键重复 complete 幂等保持终态', + 'LOADED complete 同键异参返回冲突', + 'LOADED 后新增装柜证据被拒绝', + 'LOADED 后作废装柜证据被拒绝', + 'LOADED 后运输信息修改被拒绝', + '回查 LOADED 高风险边界后的入库快照', + '回查 LOADED 高风险边界后的证据快照', + '回查 LOADED 高风险边界后的计划快照', + 'LOADED 后拒绝与幂等请求保持状态、版本、证据、计划和审计零漂移', + ]) + assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/) + assert.match(finalize, /afterHistorySignature === historySignature/) + assert.match(finalize, /afterPlanSignature === immutablePlanSignature/) +})