377 lines
18 KiB
JavaScript
377 lines
18 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
|
|
import { selectCustomsFlow } from './phase3-customs.mjs'
|
|
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
|
|
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
|
|
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
|
|
|
function source(name) {
|
|
return readFileSync(new URL(name, import.meta.url), 'utf8')
|
|
}
|
|
|
|
function assertOrder(value, snippets) {
|
|
let cursor = -1
|
|
for (const snippet of snippets) {
|
|
const next = value.indexOf(snippet)
|
|
assert.ok(next > cursor, `${snippet} must appear after the previous production step`)
|
|
cursor = next
|
|
}
|
|
}
|
|
|
|
test('pairs customs source fixture with the matching loading container', () => {
|
|
const resources = {
|
|
orders: { tenantId: 10001, orderId: 101, inboundId: 201 },
|
|
orchestrationFixtures: {
|
|
phase3Container: { tenantId: 10001, orderId: 102, inboundId: 202 },
|
|
},
|
|
phase3Loading: { tenantId: 10001, inboundId: 201, containerBookingId: 301 },
|
|
phase3ContainerLoading: { tenantId: 10001, inboundId: 202, containerBookingId: 302 },
|
|
}
|
|
const selected = selectCustomsFlow(resources)
|
|
assert.equal(selected.fixture.orderId, 101)
|
|
assert.equal(selected.container.containerBookingId, 301)
|
|
})
|
|
|
|
test('falls back to the matching container fixture without crossing source identities', () => {
|
|
const resources = {
|
|
orchestrationFixtures: { phase3Container: { tenantId: 10001, orderId: 102, inboundId: 202 } },
|
|
phase3Loading: { tenantId: 10001, inboundId: 201, containerBookingId: 301 },
|
|
phase3ContainerLoading: { tenantId: 10001, inboundId: 202, containerBookingId: 302 },
|
|
}
|
|
const selected = selectCustomsFlow(resources)
|
|
assert.equal(selected.fixture.orderId, 102)
|
|
assert.equal(selected.container.containerBookingId, 302)
|
|
})
|
|
|
|
test('records LOADING and a cleanup reason when finalize returns S0500', () => {
|
|
const result = buildFinalizeResource(
|
|
{ inboundStatus: 'LOADING', inboundVersion: 5, finalStatus: 'COMPLETED' },
|
|
{ summary: { processStatus: 'LOADING', version: 5 } },
|
|
{ http: 200, body: { code: 'S0500' } },
|
|
)
|
|
assert.equal(result.finalStatus, 'LOADING')
|
|
assert.equal(result.finalizeSucceeded, false)
|
|
assert.match(result.notCleanedReason, /S0500/)
|
|
})
|
|
|
|
test('records LOADED and clears the reason only after a real LOADED reread', () => {
|
|
const result = buildFinalizeResource(
|
|
{ inboundStatus: 'LOADING', inboundVersion: 5, finalStatus: 'COMPLETED' },
|
|
{ summary: { processStatus: 'LOADED', version: 6 } },
|
|
{ http: 200, body: { code: '200' } },
|
|
)
|
|
assert.equal(result.finalStatus, 'LOADED')
|
|
assert.equal(result.finalizeSucceeded, true)
|
|
assert.equal(result.notCleanedReason, null)
|
|
})
|
|
|
|
test('container loading follows push, claim, real pick, completed sequence', () => {
|
|
const value = source('./phase3-container-loading.mjs')
|
|
assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/)
|
|
assertOrder(value, [
|
|
"pushed?.status === 'EDITING'",
|
|
"claimedPlan?.status === 'LOADING'",
|
|
"const pickResponse = await request",
|
|
"const completeResponse = await request",
|
|
"finalPlan?.status === 'COMPLETED'",
|
|
])
|
|
assert.match(value, /picked\?\.workflowStatus === 'AWAITING_REVIEW'/)
|
|
assert.match(value, /`\$\{PDA\}\/tasks\/\$\{taskId\}\/complete`/)
|
|
assert.match(value, /expectedTaskVersion: Number\(picked\?\.version\)/)
|
|
assert.match(value, /operationId: `\$\{context\.marker\}-pda-complete`\.slice\(0, 128\)/)
|
|
})
|
|
|
|
test('bridge loading starts through a real PDA batch before preload confirmation', () => {
|
|
const value = source('./phase3-loading.mjs')
|
|
assertOrder(value, [
|
|
"pushedLoadingPlan?.status === 'EDITING'",
|
|
"claimedPlan?.status === 'LOADING'",
|
|
"const canonicalPick = await request",
|
|
"pickedPlan?.status === 'COMPLETED'",
|
|
"const confirming = await request",
|
|
])
|
|
assert.match(value, /confirmed\?\.mainStatus === 'LOADING' && confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
|
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMED'/)
|
|
assert.doesNotMatch(value, /关联柜次计划进入 LOADING/)
|
|
assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
|
|
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
|
assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === 2/)
|
|
})
|
|
|
|
test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => {
|
|
const loading = source('./phase3-loading.mjs')
|
|
assertOrder(loading, [
|
|
'读取 PDA 提交前实时库存快照',
|
|
'未发生库存变化时重复读取实时库存快照',
|
|
'客户身份无权读取平台 PDA 实时库存快照',
|
|
'const overflowResource =',
|
|
'通过公开 A0 换绑接口制造真实库存变化',
|
|
'真实库存变化后旧 snapshot token 提交返回冲突',
|
|
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
|
'库存变化后刷新 PDA 实时库存快照',
|
|
'const canonicalPick = await request',
|
|
'const canonicalBatchReplay = await request',
|
|
])
|
|
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
|
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
|
|
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
|
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
|
|
|
const convergence = source('./phase3-pda-convergence.mjs')
|
|
assert.doesNotMatch(convergence, /未公开实时库存查询或库存版本快照端点/)
|
|
assert.match(convergence, /fixture\?\.inventorySnapshotVerified !== true/)
|
|
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
|
|
})
|
|
|
|
test('container loading creates and confirms booking before scoped candidates', () => {
|
|
const value = source('./phase3-container-loading.mjs')
|
|
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
|
|
assertOrder(value, [
|
|
"const bookingCreate = await request",
|
|
"const submit = await request",
|
|
"const confirm = await request",
|
|
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
|
|
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
|
|
"const assignResponse = await request",
|
|
])
|
|
assert.match(value, /channelCode: channelCode/)
|
|
assert.match(value, /warehouseId, warehouseName/)
|
|
const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\''))
|
|
assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/)
|
|
assert.match(value, /containerAllocations\?\.\[0\]/)
|
|
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
|
|
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
|
|
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
|
|
assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport"))
|
|
assert.match(value, /绑定本轮真实箱号并补建 PDA 任务/)
|
|
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
|
|
})
|
|
|
|
test('container loading selects the current marker instead of a stale CAB fixture', () => {
|
|
const selected = selectContainerLoadingFixture({
|
|
marker: 'ADMIN-RUN-AUTO1',
|
|
createdResources: {
|
|
orchestrationFixtures: {
|
|
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
|
|
},
|
|
inboundMeasurement: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-INBOUND' },
|
|
orders: { marker: 'ADMIN-RUN-AUTO1', inboundId: 'FINANCE-ORDER-INBOUND' },
|
|
},
|
|
})
|
|
assert.equal(selected.inboundId, 'FINANCE-INBOUND')
|
|
assert.equal(selected.marker, 'ADMIN-RUN-AUTO1')
|
|
})
|
|
|
|
test('container loading keeps an unmatched candidate so marker failure stays observable', () => {
|
|
const selected = selectContainerLoadingFixture({
|
|
marker: 'ADMIN-RUN-AUTO1',
|
|
createdResources: {
|
|
orchestrationFixtures: {
|
|
phase3Container: { marker: 'ADMIN-RUN-CAB', inboundId: 'CAB-INBOUND' },
|
|
},
|
|
},
|
|
})
|
|
assert.equal(selected.marker, 'ADMIN-RUN-CAB')
|
|
})
|
|
|
|
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
|
|
const value = source('./phase3-container-loading.mjs')
|
|
assertOrder(value, [
|
|
'确认订舱并自动生成真实柜次',
|
|
'创建异渠道隔离订舱',
|
|
'异渠道入库单拒绝装入当前柜次',
|
|
'无真实柜号时计划可排柜但 PDA 任务不开放',
|
|
'绑定本轮真实箱号并补建 PDA 任务',
|
|
'换柜号前读取 PDA 稳定库存快照',
|
|
'PDA 任务创建后替换真实柜号',
|
|
'PDA 任务创建后清空真实柜号失败',
|
|
'换柜号后按稳定计划身份回查 PDA 任务',
|
|
'换柜号不改变任务计划柜次柜次号和库存快照身份',
|
|
])
|
|
assert.match(value, /containerDetail\?\.channelCode === channelCode/)
|
|
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
|
|
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
|
|
assert.match(value, /pdaIdentityStable: true/)
|
|
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
|
|
assert.doesNotMatch(batch, /containerNo:/)
|
|
})
|
|
|
|
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
|
const container = source('./phase3-container-loading.mjs')
|
|
const convergence = source('./phase3-pda-convergence.mjs')
|
|
assert.match(container, /回查完成后的 PDA 操作审计/)
|
|
assert.match(container, /item\.actionType === 'BATCH'/)
|
|
assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/)
|
|
assert.match(container, /Boolean\(item\.operatorId\) && Boolean\(item\.createdAt\)/)
|
|
assert.match(convergence, /PDA 原子批次完成审计包含 BATCH 操作/)
|
|
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, /taskItemsSignature\(unchangedAfterInvalid\) === taskItemsSignature\(beforeInvalidTask\)/)
|
|
assert.match(loading, /operationsSignature\(afterInvalidOperations\) === operationsSignature\(beforeInvalidOperations\)/)
|
|
})
|
|
|
|
test('phase3 loading proves stale inventory zero-write for all items and completion audit', () => {
|
|
const loading = source('./phase3-loading.mjs')
|
|
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/)
|
|
assertOrder(loading, [
|
|
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
|
'库存变化后刷新 PDA 实时库存快照',
|
|
'原子提交柜次 PDA 整批拣货',
|
|
'PDA 原子批次同参同键重放返回同一终态',
|
|
'回查原子 Batch 完成审计',
|
|
'合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW',
|
|
])
|
|
})
|
|
|
|
test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {
|
|
const entry = source('../admin-test-env-acceptance.mjs')
|
|
const measurement = source('./inbound-measurement.mjs')
|
|
const orders = source('./orders.mjs')
|
|
assertOrder(entry, [
|
|
'disableAvailableLocationsForA0(warehouseId)',
|
|
'this.context.phase3LoadingRequireSharedOverflow = true',
|
|
"executeModule('inbound-measurement-phase3-loading-fixture'",
|
|
'restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)',
|
|
])
|
|
assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/)
|
|
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
|
|
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
|
|
assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/)
|
|
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
|
|
})
|
|
|
|
test('customs provisions released booking and completed truck for the selected loading container', () => {
|
|
const value = source('./phase3-customs.mjs')
|
|
assertOrder(value, [
|
|
'selectCustomsFlow(context.createdResources)',
|
|
'if (!await ensureServiceCenterFlow(context, container)) return',
|
|
'const slicesPath =',
|
|
])
|
|
assert.match(value, /oceanBookings\.run\(context\)/)
|
|
assert.match(value, /serviceCenter\.run\(context\)/)
|
|
})
|
|
|
|
test('downstream producers prefer the phase3Loading container fixture', () => {
|
|
const ocean = source('./ocean-bookings.mjs')
|
|
const customs = source('./phase3-customs.mjs')
|
|
const serviceCenter = source('./service-center-flow.mjs')
|
|
assert.match(ocean, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
|
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
|
|
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
|
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
|
|
})
|
|
|
|
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
|
|
const entry = source('../admin-test-env-acceptance.mjs')
|
|
assertOrder(entry, [
|
|
"'phase3-loading'",
|
|
"'phase3-loading-model-bridge'",
|
|
"'phase3-pda-convergence'",
|
|
"'phase3-evidence-gate'",
|
|
"'phase3-customs'",
|
|
"'phase3-loading-finalize'",
|
|
])
|
|
})
|
|
|
|
test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => {
|
|
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
|
|
const entry = source('../admin-test-env-acceptance.mjs')
|
|
assert.match(entry, /module === 'phase3-loading'/)
|
|
assert.match(entry, /preparePhase3LoadingFixture\(\)/)
|
|
assert.match(entry, /orders-phase3-loading-fixture/)
|
|
assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
|
|
assert.match(entry, /marker: originalMarker/)
|
|
assert.match(entry, /sourceMarker: fixtureMarker/)
|
|
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
|
|
assert.equal(run.marker.includes('-LOAD'), false)
|
|
})
|
|
|
|
test('container fixture keeps the run marker and does not publish incomplete WAIT_PRICING resources', () => {
|
|
const entry = source('../admin-test-env-acceptance.mjs')
|
|
assert.match(entry, /const fixtureMarker = originalMarker/)
|
|
assert.match(entry, /fixture\.finalStatus !== 'APPROVED'/)
|
|
assert.match(entry, /禁止回退使用 WAIT_PRICING 订单/)
|
|
})
|
|
|
|
test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
|
|
const measurement = source('./inbound-measurement.mjs')
|
|
assert.match(measurement, /waitForCompletedMeasurement/)
|
|
assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
|
|
assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
|
|
})
|
|
|
|
test('customs and finalize consume the same loading inbound identity', () => {
|
|
const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 }
|
|
const loading = {
|
|
marker: 'RUN-1', tenantId: 10001, inboundId: 201,
|
|
containerBookingId: 301, loadingPlanId: 401, finalStatus: 'COMPLETED',
|
|
}
|
|
const selected = selectCustomsFlow({
|
|
orchestrationFixtures: {
|
|
phase3Loading: loadingFixture,
|
|
inboundMeasurement: loadingFixture,
|
|
},
|
|
phase3Loading: loading,
|
|
})
|
|
assert.equal(selected.fixture.marker, loading.marker)
|
|
assert.equal(selected.fixture.inboundId, loading.inboundId)
|
|
assert.equal(selected.container.inboundId, loading.inboundId)
|
|
|
|
const finalized = buildFinalizeResource(loading, {
|
|
summary: { processStatus: 'LOADED', version: 6 },
|
|
}, { http: 200, body: { code: '200' } })
|
|
assert.equal(finalized.marker, loading.marker)
|
|
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/)
|
|
})
|