This commit is contained in:
2026-08-12 12:35:29 +08:00
parent 0fceece378
commit e4f65dae5f
8 changed files with 434 additions and 41 deletions
@@ -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 })
@@ -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) {
@@ -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)
})
@@ -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,
@@ -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)
}
@@ -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)
}
@@ -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,
@@ -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/)
})