优化
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user