This commit is contained in:
2026-08-17 15:41:42 +08:00
parent 4abcb2b80f
commit 5f79616623
60 changed files with 1457 additions and 270 deletions
+5 -3
View File
@@ -46,7 +46,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
@@ -62,9 +62,11 @@ export async function run(context) {
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> 拖车与报关清关门禁 -> PDA task complete 同事务完成 task/plan/inbound -> LOADED`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:服务端预检同柜 `RELEASED` 订舱、`COMPLETED` 拖车、五类 ACTIVE 证据和 bridge,缺项返回 A0409 且 task/plan/inbound 零写入;全部满足后同一事务推进 `LOADED`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
部署后需要定向验证该完整链时,模块顺序固定为:
@@ -87,10 +87,16 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
return ok(permissions)
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
return ok({ records: [{ customerId: '11', accountName: 'Fixture Account' }] })
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
lastChangedAt: '2026-08-10T09:00:00' }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/finance/v1/accounts/11/ledger?')) {
return ok({ records: [{ customerId: '11', statementId: '1', remark: marker }] })
return ok({ records: [{ customerId: '11', statementId: '1', ledgerId: '1',
occurredAt: '2026-08-10T09:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL',
documentId: '1', documentNo: 'REC-1', receivableChange: 100, payableChange: 0,
outstandingReceivableAfter: 100, operatorName: 'operator-7001', remark: marker }] })
}
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) {
return ok({ records: [{ id: '11', status: 'ACTIVE' }, { id: '12', status: 'ACTIVE' }] })
@@ -225,6 +231,10 @@ test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输
assert.match(source, /\/accounts\/\$\{encodeURIComponent\(customerId\)\}\/ledger\?page=1&pageSize=200/)
assert.match(source, /balance.*recharge.*prepayment/)
assert.match(source, /accountReadback/)
assert.match(source, /accountInformationComplete/)
assert.match(source, /ledgerInformationComplete/)
assert.match(source, /客户账户基本信息、四项金额和最近变动时间完整/)
assert.match(source, /客户账户流水时间、业务类型、关联单据、金额和操作人完整/)
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
assert.match(source, new RegExp(`['"]${action}['"]`))
}
@@ -129,6 +129,18 @@ function accountCustomerId(value) { return id(value?.customerId ?? value?.tenant
function hasWalletField(value) {
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
}
function accountInformationComplete(value) {
return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt']
.every((field) => present(value?.[field]))
&& ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable']
.every((field) => Number.isFinite(Number(value?.[field])))
}
function ledgerInformationComplete(value) {
return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName']
.every((field) => present(value?.[field]))
&& ['receivableChange', 'payableChange', 'outstandingReceivableAfter']
.every((field) => Number.isFinite(Number(value?.[field])))
}
async function verifyCustomerAccountAndLedger(context, fixture) {
const customerId = id(fixture.customerId)
const accountResponse = await context.request('GET',
@@ -138,6 +150,9 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
Boolean(account) && !accounts.some(hasWalletField),
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse,
accountInformationComplete(account),
`customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS)
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
const ledger = accountRows(ledgerResponse)
const rowsForCustomer = ledger.filter((item) => {
@@ -155,6 +170,10 @@ async function verifyCustomerAccountAndLedger(context, fixture) {
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
rowsForCustomer.length === ledger.length && containsRoundTrip,
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item))
context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse,
rowsForCustomer.length > 0 && incompleteLedger.length === 0,
`customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS)
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
}
@@ -208,7 +208,7 @@ export async function run(context) {
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportMatches) return
const allocatedContainerBookingId = ''
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
+6 -4
View File
@@ -266,14 +266,16 @@ function buildPayload(context, profile, master, productImageFileId) {
cargoes: BOXES.map((box, index) => {
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
unitsPerPackage, packageCount: 1, grossWeightKg: 10 + index,
volumeCbm, totalQuantity: unitsPerPackage, netWeightKg: 9 + index,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * 8.5, currency: 'USD',
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
@@ -294,7 +296,7 @@ function assertCargoContract(context, response, cargoes, payload) {
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
&& Number(actual?.packageCount) === 1 && Number(actual?.totalQuantity) === expected.totalQuantity
&& Number(actual?.packageCount) === expected.packageCount && Number(actual?.totalQuantity) === expected.totalQuantity
&& Number(actual?.netWeightKg) === expected.netWeightKg
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
&& actual?.material === expected.material && actual?.productUsage === expected.usage
@@ -130,7 +130,9 @@ export async function run(context) {
const containerBookingId = id(container?.containerBookingId)
const loadingPlanId = id(container?.loadingPlanId)
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId
|| !['COMPLETED', 'LOADED'].includes(container?.finalStatus)) {
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|| (container?.finalStatus === 'AWAITING_REVIEW'
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
`tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
return
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const read = (path) => readFileSync(resolve(here, path), 'utf8')
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const pdaOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml')
const entry = read('../admin-test-env-acceptance.mjs')
const loading = read('./phase3-loading.mjs')
const convergence = read('./phase3-pda-convergence.mjs')
const customs = read('./phase3-customs.mjs')
const finalize = read('./phase3-loading-finalize.mjs')
function ordered(source, fragments) {
let cursor = -1
for (const fragment of fragments) {
const next = source.indexOf(fragment, cursor + 1)
assert.ok(next > cursor, `${fragment} must appear in frozen order`)
cursor = next
}
}
test('OpenAPI freezes LOADING incremental plan capabilities and optimistic versions', () => {
for (const endpoint of [
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:',
'/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:',
]) assert.ok(firstlegOpenApi.includes(endpoint), `${endpoint} missing`)
for (const field of [
'inboundId', 'inboundDetailId', 'packageCount', 'expectedVersion', 'operationId',
'expectedPlanVersion', 'expectedItemVersion', 'canAssign', 'canEdit', 'blockedReason',
'finalCompletionStatus', 'pdaTaskId', 'pdaTaskStatus', 'pdaTaskVersion',
'pickedPackageCount', 'minPackageCount', 'canAdjust', 'canRemove',
]) assert.match(firstlegOpenApi, new RegExp(`\\b${field}\\b`))
assert.match(firstlegOpenApi, /enum: \[NOT_STARTED, IN_PROGRESS, COMPLETED\]/)
})
test('default fixture publishes a second marker-owned inbound for the same-plan increment', () => {
assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD-INC`/)
assert.match(entry, /orders-phase3-loading-incremental-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/)
assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/)
assert.match(entry, /phase3LoadingIncremental: incrementalFixture/)
assert.match(entry, /marker: originalMarker/)
assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i)
})
test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => {
ordered(loading, [
'PDA 原子批次完成拣货并等待人工确认',
'五类装柜证据完整且铅封号持久化',
'LOADING 阶段向同一 PDA task 增加入库单',
'增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁',
'增量后旧 inventory token 明确 A0409 并要求刷新',
'增量旧 token 冲突后 plan/task/items 零写入',
'未拣动态增量明细允许删除',
'未拣动态增量明细允许调整数量',
'调整数量递增 plan/task/item 版本并保持稳定任务',
'重新增量后刷新 PDA task detail',
'重新增量后刷新 inventory snapshot',
'动态新增项拣完后同一任务重新进入 AWAITING_REVIEW',
'已拣项减量低于 minPackageCount 返回 A0409',
'已拣项删除返回 A0409',
'已拣项删除和减量失败后 plan/task/items 零写入',
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
])
assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/)
assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/)
assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/)
assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/)
assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/)
})
test('downstream modules keep the task pending until the transactional PDA final complete', () => {
assert.match(convergence, /summary\?\.status === 'LOADING'/)
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
ordered(finalize, [
'最终完成前拖车与报关清关门禁属于同一稳定柜次',
'最终完成前四元身份稳定且任务等待人工确认',
'读取最终完成并发前计划即时版本',
'Admin 增量与 PDA final complete 并发恰好一个事务成功',
'订舱拖车报关齐备后完成装柜',
'统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移',
'入库单终态 LOADED、版本递增且审计历史存在',
'统一硬门禁通过后五类有效证据仍完整',
'COMPLETED 后新增计划明细被拒绝',
'COMPLETED 后调整计划明细被拒绝',
'COMPLETED 后删除计划明细被拒绝',
])
assert.match(finalize, /`\$\{taskPath\}\/complete`/)
assert.match(finalize, /await Promise\.all\(/)
assert.match(finalize, /completeWon !== adjustWon/)
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
})
test('PDA final completion OpenAPI freezes zero-write gate semantics', () => {
assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/)
assert.match(pdaOpenApi, /先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据/)
assert.match(pdaOpenApi, /任何阻断返回A0409且task、plan、inbound仍保持LOADING/)
assert.match(pdaOpenApi, /全部满足后在同一事务内结算资源、完成task\/plan并推进inbound为LOADED/)
})
@@ -50,8 +50,11 @@ export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
const orderId = id(fixture?.orderId)
const taskId = id(fixture?.canonicalPdaTaskId); const loadingPlanId = id(fixture?.loadingPlanId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
|| !taskId || !loadingPlanId || !containerBookingId
|| fixture?.loadingPlanStatus !== 'LOADING' || fixture?.finalStatus !== 'AWAITING_REVIEW') {
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
return
@@ -66,30 +69,93 @@ export async function run(context) {
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const completeResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
const truck = context.createdResources?.serviceCenterFlow
const customs = context.createdResources?.phase3Customs
context.assert(MODULE, '最终完成前拖车与报关清关门禁属于同一稳定柜次', beforeResponse,
truck?.marker === context.marker && truck?.finalStatus === 'COMPLETED'
&& id(truck?.containerBookingId) === containerBookingId
&& customs?.marker === context.marker && customs?.finalStatus === 'CLEARANCE_COMPLETED'
&& id(customs?.containerBookingId) === containerBookingId,
`truck=${truck?.finalStatus || 'missing'}, customs=${customs?.finalStatus || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
const taskBeforeResponse = await context.request('GET', taskPath)
const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS)
context.assert(MODULE, '最终完成前四元身份稳定且任务等待人工确认', taskBeforeResponse,
id(taskBefore?.id) === taskId && id(taskBefore?.inboundId) === inboundId
&& id(taskBefore?.loadingPlanId) === loadingPlanId
&& id(taskBefore?.containerBookingId) === containerBookingId
&& taskBefore?.taskStatus === 'LOADING' && taskBefore?.workflowStatus === 'AWAITING_REVIEW',
`taskId=${id(taskBefore?.id) || 'missing'}, workflow=${taskBefore?.workflowStatus || 'missing'}, version=${taskBefore?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(Number(taskBefore?.version))) return
const planPath = `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`
const planBeforeRaceResponse = await context.request('GET', planPath)
const planBeforeRace = context.apiSuccess(MODULE, '读取最终完成并发前计划即时版本', planBeforeRaceResponse, '', OPTIONS)
const raceItem = planBeforeRace?.items?.find?.((item) => Number(item?.pickedPackageCount) > 0)
context.assert(MODULE, '最终完成并发使用同一已拣计划明细与即时版本', planBeforeRaceResponse,
Boolean(raceItem?.id) && Number.isInteger(Number(planBeforeRace?.version))
&& Number.isInteger(Number(raceItem?.version)),
`planVersion=${planBeforeRace?.version ?? 'missing'}, itemId=${id(raceItem?.id) || 'missing'}, itemVersion=${raceItem?.version ?? 'missing'}`, OPTIONS)
if (!raceItem?.id || !Number.isInteger(Number(planBeforeRace?.version))) return
let completeBody = {
expectedTaskVersion: Number(taskBefore.version),
operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker,
}
let completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128)
const concurrentAdjustBody = {
packageCount: Number(raceItem.packageCount), expectedPlanVersion: Number(planBeforeRace.version),
expectedItemVersion: Number(raceItem.version), operationId: `${context.marker}-race-adjust`.slice(0, 128),
}
const [initialCompleteResponse, concurrentAdjustResponse] = await Promise.all([
request(context, 'POST', `${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey }),
request(context, 'PATCH',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(raceItem.id))}`,
concurrentAdjustBody),
])
const completeWon = initialCompleteResponse.http === 200 && codeOf(initialCompleteResponse) === '200'
const adjustWon = concurrentAdjustResponse.http === 200 && codeOf(concurrentAdjustResponse) === '200'
context.assert(MODULE, 'Admin 增量与 PDA final complete 并发恰好一个事务成功', initialCompleteResponse,
completeWon !== adjustWon
&& (completeWon ? codeOf(concurrentAdjustResponse) === 'A0409' : codeOf(initialCompleteResponse) === 'A0409'),
`completeCode=${codeOf(initialCompleteResponse)}, adjustCode=${codeOf(concurrentAdjustResponse)}, completeRequestId=${initialCompleteResponse.requestId || 'missing'}, adjustRequestId=${concurrentAdjustResponse.requestId || 'missing'}`, OPTIONS)
let completeResponse = initialCompleteResponse
if (adjustWon) {
const taskAfterRaceResponse = await context.request('GET', taskPath)
const taskAfterRace = context.apiSuccess(MODULE, '增量赢得并发后刷新 PDA 任务版本', taskAfterRaceResponse, '', OPTIONS)
const planAfterRaceResponse = await context.request('GET', planPath)
const planAfterRace = context.apiSuccess(MODULE, '增量赢得并发后回查唯一计划事实', planAfterRaceResponse, '', OPTIONS)
context.assert(MODULE, 'final complete 失败方零写入且增量版本成为唯一事实', taskAfterRaceResponse,
taskAfterRace?.taskStatus === 'LOADING' && taskAfterRace?.workflowStatus === 'AWAITING_REVIEW'
&& Number(taskAfterRace?.version) > Number(taskBefore?.version)
&& Number(planAfterRace?.version) > Number(planBeforeRace?.version),
`taskVersion=${taskAfterRace?.version ?? 'missing'}, planVersion=${planAfterRace?.version ?? 'missing'}`, OPTIONS)
completeBody = {
expectedTaskVersion: Number(taskAfterRace?.version),
operationId: `${context.marker}-pda-final-complete-retry`.slice(0, 128), remark: context.marker,
}
completeKey = `${context.marker}-p3-complete-after-race-refresh`.slice(0, 128)
completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey })
}
const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS)
const evidences = completed?.evidences?.evidences || []
context.assert(MODULE, '统一硬门禁通过后推进 LOADED 且五类有效证据仍完整', completeResponse,
completed?.mainStatus === 'LOADED' && fixture.evidenceTypes?.every?.((type) =>
evidences.some((item) => item.evidenceType === type && item.status !== 'VOID')),
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${evidences.length}`, OPTIONS)
context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId
&& id(completed?.containerBookingId) === containerBookingId && id(completed?.inboundId) === inboundId,
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
const replayResponse = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: version, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) })
`${taskPath}/complete`, completeBody, { 'Idempotency-Key': completeKey })
context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse,
replayResponse.http === 200 && String(replayResponse.body?.code) === '200'
&& replayResponse.body?.data?.mainStatus === 'LOADED',
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.mainStatus || 'missing'}`, OPTIONS)
&& replayResponse.body?.data?.taskStatus === 'COMPLETED'
&& id(replayResponse.body?.data?.id) === taskId,
`HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.taskStatus || 'missing'}`, OPTIONS)
const finalResponse = await context.request('GET', detailPath)
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === version + 1
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) > version
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING'
&& 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)
@@ -114,7 +180,10 @@ export async function run(context) {
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))}`
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
item.evidenceType === type && item.status !== 'VOID')),
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}`, OPTIONS)
const immutablePlanResponse = await context.request('GET', planPath)
const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS)
const finalVersion = Number(finalData?.summary?.version)
@@ -130,18 +199,19 @@ export async function run(context) {
})
const completeNewKey = await request(context, 'POST',
`${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: finalVersion, remark: `${context.marker}-loaded-new-key` },
`${taskPath}/complete`,
{ expectedTaskVersion: Number(completed?.version),
operationId: `${context.marker}-pda-final-complete-new-key`.slice(0, 128), 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)
&& completeNewKey.body?.data?.taskStatus === 'COMPLETED',
`HTTP=${completeNewKey.http}, code=${codeOf(completeNewKey)}, status=${completeNewKey.body?.data?.taskStatus || '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) })
`${taskPath}/complete`,
{ ...completeBody, remark: `${context.marker}-different-complete-body` },
{ 'Idempotency-Key': completeKey })
context.addStep(MODULE, 'LOADED complete 同键异参返回冲突', completeSameKeyDifferentBody,
completeSameKeyDifferentBody.http === 200 && codeOf(completeSameKeyDifferentBody) === 'A0409'
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
@@ -175,6 +245,34 @@ export async function run(context) {
&& Boolean(immutableTransport.requestId) && Boolean(immutableTransport.traceId),
`HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS)
const completedPlanItem = immutablePlan?.items?.[0]
const assignmentAfterCompleted = await request(context, 'POST',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/assignments`, {
inboundId: fixture.incrementalInboundId || inboundId,
inboundDetailId: id(completedPlanItem?.inboundDetailId), packageCount: 1,
expectedVersion: Number(immutablePlan?.version),
operationId: `${context.marker}-assign-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后新增计划明细被拒绝', assignmentAfterCompleted,
assignmentAfterCompleted.http === 200 && codeOf(assignmentAfterCompleted) === 'A0409',
`HTTP=${assignmentAfterCompleted.http}, code=${codeOf(assignmentAfterCompleted)}`, OPTIONS)
const adjustAfterCompleted = await request(context, 'PATCH',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
packageCount: Number(completedPlanItem?.packageCount || 1), expectedPlanVersion: Number(immutablePlan?.version),
expectedItemVersion: Number(completedPlanItem?.version), operationId: `${context.marker}-adjust-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后调整计划明细被拒绝', adjustAfterCompleted,
adjustAfterCompleted.http === 200 && codeOf(adjustAfterCompleted) === 'A0409',
`HTTP=${adjustAfterCompleted.http}, code=${codeOf(adjustAfterCompleted)}`, OPTIONS)
const deleteAfterCompleted = await request(context, 'DELETE',
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/items/${encodeURIComponent(id(completedPlanItem?.id))}`, {
expectedPlanVersion: Number(immutablePlan?.version), expectedItemVersion: Number(completedPlanItem?.version),
operationId: `${context.marker}-delete-after-completed`.slice(0, 128),
})
context.addStep(MODULE, 'COMPLETED 后删除计划明细被拒绝', deleteAfterCompleted,
deleteAfterCompleted.http === 200 && codeOf(deleteAfterCompleted) === 'A0409',
`HTTP=${deleteAfterCompleted.http}, code=${codeOf(deleteAfterCompleted)}`, OPTIONS)
const afterBoundaryResponse = await context.request('GET', detailPath)
const afterBoundary = context.apiSuccess(MODULE, '回查 LOADED 高风险边界后的入库快照', afterBoundaryResponse, '', OPTIONS)
const afterEvidenceResponse = await context.request('GET', evidencePath)
+217 -50
View File
@@ -652,39 +652,28 @@ export async function run(context) {
atomicBatchReplayVerified,
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, {
expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker,
})
const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS)
const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED'
&& completedCanonical?.workflowStatus === 'COMPLETED'
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'),
`taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS)
context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse,
Array.isArray(completionOperations)
&& completionOperations.some((item) => item.actionType === 'CLAIM'
const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前原子 Batch 审计', preFinalOperationsResponse, '', OPTIONS)
context.assert(MODULE, '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', preFinalOperationsResponse,
Array.isArray(preFinalOperations)
&& preFinalOperations.some((item) => item.actionType === 'CLAIM'
&& id(item.operatorId) && Boolean(item.createdAt))
&& completionOperations.some((item) => item.actionType === 'BATCH'
&& preFinalOperations.some((item) => item.actionType === 'BATCH'
&& id(item.operatorId) && Boolean(item.createdAt))
&& completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING'
&& id(item.operatorId) && Boolean(item.createdAt))
&& !completionOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
context.assert(MODULE, '下游门禁前柜次计划保持 LOADING 且等待最终完成', pickedPlanResponse,
pickedPlan?.status === 'LOADING' && pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload,
context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
&& preloadData?.status === 'CONFIRMED',
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
@@ -697,18 +686,6 @@ export async function run(context) {
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(loadingVersion)) return
// COMPLETE_LOADING is the only positive completion action; REVIEW is a
// negative compatibility assertion after the task is already complete.
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, {
expectedTaskVersion: Number(completedCanonical?.version),
operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
})
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
directPdaCompletion
? canonicalReview.http === 200 && codeOf(canonicalReview) === 'A0409'
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
'本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
@@ -759,8 +736,9 @@ export async function run(context) {
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
if (evidenceType === 'PHOTO_STEP_4') {
const missingSeal = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: loadingVersion, remark: 'must require seal' },
const missingSeal = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-complete-before-seal`.slice(0, 128), remark: 'must require seal' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) })
context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal),
`HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS)
@@ -864,8 +842,10 @@ export async function run(context) {
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` },
const missingTypeComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(pickedCanonical?.version),
operationId: `${context.marker}-complete-missing-photo-step-4`.slice(0, 128),
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',
@@ -916,6 +896,178 @@ export async function run(context) {
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental
const incrementalInboundId = id(incrementalFixture?.inboundId)
let dynamicIncrementVerified = false
if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
`inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS)
} else {
const dynamicCandidatesResponse = await context.request('GET',
`${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS)
const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) =>
id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null
context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse,
Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId
&& id(dynamicCandidate?.warehouseId) === warehouseId
&& dynamicCandidate?.channelCode === initial?.summary?.channelCode,
`inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (dynamicCandidate?.inboundDetailId) {
const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS)
const taskBeforeIncrementResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const taskBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前任务', taskBeforeIncrementResponse, '', OPTIONS)
const evidenceBeforeIncrement = JSON.stringify(evidenceListData?.evidences || [])
const incrementResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(planBeforeIncrement?.version),
operationId: `${context.marker}-dynamic-assign`.slice(0, 128),
})
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id))
const evidenceAfterIncrementResponse = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS)
context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse,
id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING'
&& incrementedPlan?.finalCompletionStatus === 'IN_PROGRESS'
&& id(incrementedPlan?.pdaTaskId) === canonicalTaskId && incrementedPlan?.pdaTaskStatus === 'LOADING'
&& Number(incrementedPlan?.version) > Number(planBeforeIncrement?.version)
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
&& Number(incrementalPlanItem?.minPackageCount) === 1
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
&& id(incrementedTask?.containerBookingId) === containerBookingId
&& incrementedTask?.workflowStatus === 'IN_PROGRESS'
&& JSON.stringify(evidenceAfterIncrement?.evidences || []) === evidenceBeforeIncrement,
`planVersion=${incrementedPlan?.version ?? 'missing'}, taskVersion=${incrementedTask?.version ?? 'missing'}, workflow=${incrementedTask?.workflowStatus || 'missing'}`, OPTIONS)
if (incrementalTaskItem?.id) {
const staleIncrementBatch = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(incrementedTask?.version),
inventorySnapshotToken: effectiveBatchPayload.inventorySnapshotToken,
items: [{ itemId: id(incrementalTaskItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(incrementalTaskItem.version) }],
}, { 'Idempotency-Key': `${context.marker}-dynamic-stale-inventory`.slice(0, 128) })
context.addStep(MODULE, '增量后旧 inventory token 明确 A0409 并要求刷新', staleIncrementBatch,
staleIncrementBatch.http === 200 && codeOf(staleIncrementBatch) === 'A0409'
&& String(staleIncrementBatch.body?.message || '').includes('库存已变化,请刷新后重试'),
responseEvidence(staleIncrementBatch), OPTIONS)
const taskAfterStaleResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const taskAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量任务', taskAfterStaleResponse, '', OPTIONS)
const planAfterStaleResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const planAfterStale = context.apiSuccess(MODULE, '旧 token 冲突后回查增量计划', planAfterStaleResponse, '', OPTIONS)
context.assert(MODULE, '增量旧 token 冲突后 plan/task/items 零写入', taskAfterStaleResponse,
Number(taskAfterStale?.version) === Number(incrementedTask?.version)
&& taskItemsSignature(taskAfterStale) === taskItemsSignature(incrementedTask)
&& Number(planAfterStale?.version) === Number(incrementedPlan?.version),
`taskVersion=${taskAfterStale?.version ?? 'missing'}, planVersion=${planAfterStale?.version ?? 'missing'}`, OPTIONS)
const removeResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(incrementalPlanItem.id))}`, {
expectedPlanVersion: Number(incrementedPlan.version), expectedItemVersion: Number(incrementalPlanItem.version),
operationId: `${context.marker}-dynamic-remove-unpicked`.slice(0, 128),
})
const removedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许删除', removeResponse, '', OPTIONS)
context.assert(MODULE, '删除未拣项递增计划和任务版本且稳定身份不变', removeResponse,
!removedPlan?.items?.some?.((item) => id(item?.id) === id(incrementalPlanItem.id))
&& Number(removedPlan?.version) > Number(incrementedPlan?.version)
&& Number(removedPlan?.pdaTaskVersion) > Number(incrementedPlan?.pdaTaskVersion)
&& id(removedPlan?.pdaTaskId) === canonicalTaskId && removedPlan?.status === 'LOADING',
`planVersion=${removedPlan?.version ?? 'missing'}, taskVersion=${removedPlan?.pdaTaskVersion ?? 'missing'}`, OPTIONS)
const reassignResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(removedPlan?.version), operationId: `${context.marker}-dynamic-reassign`.slice(0, 128),
})
const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS)
let effectiveDynamicPlan = reassignedPlan
let reassignedPlanItem = reassignedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse,
Number(dynamicCandidate?.availablePackageCount) >= 2,
`availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (Number(dynamicCandidate?.availablePackageCount) < 2) return
if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) {
const adjustResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
packageCount: 2, expectedPlanVersion: Number(reassignedPlan?.version),
expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-adjust`.slice(0, 128),
})
const adjustedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许调整数量', adjustResponse, '', OPTIONS)
const adjustedItem = adjustedPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem.id))
context.assert(MODULE, '调整数量递增 plan/task/item 版本并保持稳定任务', adjustResponse,
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
&& Number(adjustedItem?.packageCount) === 2 && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan
reassignedPlanItem = adjustedItem
}
const refreshedDynamicTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
const refreshedDynamicTaskItem = refreshedDynamicTask?.items?.find?.((item) =>
id(item?.loadingPlanItemId) === id(reassignedPlanItem?.id))
const refreshedDynamicInventoryResponse = await context.request('GET',
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const refreshedDynamicInventory = context.apiSuccess(MODULE, '重新增量后刷新 inventory snapshot', refreshedDynamicInventoryResponse, '', OPTIONS)
const dynamicBatchResponse = await request(context, 'POST', batchPath, {
warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(refreshedDynamicTask?.version),
inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken,
items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount),
releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }],
}, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) })
const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS)
context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse,
id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& dynamicPickedTask?.taskStatus === 'LOADING'
&& dynamicPickedTask?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
`taskId=${id(dynamicPickedTask?.id) || 'missing'}, workflow=${dynamicPickedTask?.workflowStatus || 'missing'}`, OPTIONS)
const pickedDynamicPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedDynamicPlan = context.apiSuccess(MODULE, '动态新增项拣完后回查 minPackageCount', pickedDynamicPlanResponse, '', OPTIONS)
const pickedDynamicPlanItem = pickedDynamicPlan?.items?.find?.((item) => id(item?.id) === id(reassignedPlanItem?.id))
const beforePickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
const reducePickedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
packageCount: Math.max(1, Number(pickedDynamicPlanItem?.minPackageCount) - 1),
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项减量低于 minPackageCount 返回 A0409', reducePickedResponse,
reducePickedResponse.http === 200 && codeOf(reducePickedResponse) === 'A0409', responseEvidence(reducePickedResponse), OPTIONS)
const deletePickedResponse = await request(context, 'DELETE',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, {
expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-delete-picked`.slice(0, 128),
})
context.addStep(MODULE, '已拣项删除返回 A0409', deletePickedResponse,
deletePickedResponse.http === 200 && codeOf(deletePickedResponse) === 'A0409', responseEvidence(deletePickedResponse), OPTIONS)
const afterPickedMutationPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterPickedMutationPlan = context.apiSuccess(MODULE, '已拣项负向后回查计划', afterPickedMutationPlanResponse, '', OPTIONS)
const afterPickedMutationTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const afterPickedMutationTask = context.apiSuccess(MODULE, '已拣项负向后回查任务', afterPickedMutationTaskResponse, '', OPTIONS)
context.assert(MODULE, '已拣项删除和减量失败后 plan/task/items 零写入', afterPickedMutationPlanResponse,
Number(afterPickedMutationPlan?.version) === Number(pickedDynamicPlan?.version)
&& Number(afterPickedMutationTask?.version) === Number(beforePickedMutationTask?.version)
&& taskItemsSignature(afterPickedMutationTask) === taskItemsSignature(beforePickedMutationTask),
`planVersion=${afterPickedMutationPlan?.version ?? 'missing'}, taskVersion=${afterPickedMutationTask?.version ?? 'missing'}`, OPTIONS)
dynamicIncrementVerified = dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW'
&& Number(pickedDynamicPlanItem?.minPackageCount) === Number(reassignedPlanItem?.packageCount)
&& Number(effectiveDynamicPlan?.version) <= Number(pickedDynamicPlan?.version)
}
}
}
const beforeHardGateResponse = await context.request('GET', detailPath)
const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS)
const beforeHardGateVersion = Number(beforeHardGate?.summary?.version)
@@ -923,18 +1075,31 @@ export async function run(context) {
beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion),
`status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(beforeHardGateVersion)) return
const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` },
const beforeBlockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const beforeBlockedTask = context.apiSuccess(MODULE, '读取下游门禁拒绝前 PDA 任务快照', beforeBlockedTaskResponse, '', OPTIONS)
const beforeBlockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeBlockedPlan = context.apiSuccess(MODULE, '读取下游门禁拒绝前计划快照', beforeBlockedPlanResponse, '', OPTIONS)
const blockedBeforeDownstream = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`,
{ expectedTaskVersion: Number(beforeBlockedTask?.version),
operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
failure(blockedBeforeDownstream),
`HTTP=${blockedBeforeDownstream.http}, code=${codeOf(blockedBeforeDownstream)}`, OPTIONS)
blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409',
responseEvidence(blockedBeforeDownstream), OPTIONS)
const blockedDetailResponse = await context.request('GET', detailPath)
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
const blockedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const blockedTask = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的 PDA 任务', blockedTaskResponse, '', OPTIONS)
const blockedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const blockedPlan = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的计划', blockedPlanResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', blockedDetailResponse,
blockedDetail?.summary?.processStatus === 'LOADING'
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion,
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
&& Number(blockedDetail?.summary?.version) === beforeHardGateVersion
&& Number(blockedTask?.version) === Number(beforeBlockedTask?.version)
&& taskItemsSignature(blockedTask) === taskItemsSignature(beforeBlockedTask)
&& Number(blockedPlan?.version) === Number(beforeBlockedPlan?.version)
&& blockedPlan?.status === beforeBlockedPlan?.status,
`inboundStatus=${blockedDetail?.summary?.processStatus || 'missing'}, inboundVersion=${blockedDetail?.summary?.version ?? 'missing'}, taskVersion=${blockedTask?.version ?? 'missing'}, planVersion=${blockedPlan?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
@@ -946,9 +1111,11 @@ export async function run(context) {
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
inventoryMutationVerified, inventoryMutationCleanup,
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
dynamicIncrementVerified, incrementalInboundId,
pdaTaskVersion: Number(blockedTask?.version),
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
}
}
@@ -28,26 +28,26 @@ export async function run(context) {
}
const loadingListResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/loading-lists/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 完成后的装柜清单', loadingListResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单保留 PDA 完成终态和操作者审计', loadingListResponse,
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'COMPLETED'
&& loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'
&& Boolean(item.operatorName) && Boolean(item.occurredAt)),
const loadingList = context.apiSuccess(MODULE, '回查柜次 PDA 待最终完成的装柜清单', loadingListResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单保持 LOADING 且尚无最终完成审计', loadingListResponse,
id(loadingList?.summary?.planId) === loadingPlanId && loadingList?.summary?.status === 'LOADING'
&& !loadingList?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING'),
`planId=${id(loadingList?.summary?.planId) || 'missing'}, status=${loadingList?.summary?.status || 'missing'}`, OPTIONS)
const projectionResponse = await request(context, 'GET',
`/api/platform/v1/inbounds/${inboundId}/loading/picking?tenantId=${encodeURIComponent(tenantId)}`)
const projection = context.apiSuccess(MODULE, '查询入库级 PDA 服务端聚合投影', projectionResponse, '', OPTIONS)
context.assert(MODULE, '入库投影与柜次事实数量和终态一致', projectionResponse,
projection?.status === 'COMPLETED' && Number(projection?.expectedCount) > 0
context.assert(MODULE, '入库投影数量已拣齐但仍等待最终门禁', projectionResponse,
['AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0
&& Number(projection?.expectedCount) === Number(projection?.pickedCount),
`status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS)
const operationsResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`)
const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS)
context.assert(MODULE, '直接完成契约保留领取操作且不伪造复核历史', operationsResponse,
context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse,
Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
&& !operations.some((item) => item.actionType === 'REVIEW'),
&& !operations.some((item) => item.actionType === 'REVIEW')
&& !operations.some((item) => item.actionType === 'COMPLETE_LOADING'),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
@@ -101,7 +101,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'读取柜次级 PDA 唯一事实任务',
"claimedPlan?.status === 'LOADING'",
"const canonicalPick = await request",
"pickedPlan?.status === 'COMPLETED'",
"pickedPlan?.status === 'LOADING'",
"pickedPlan?.finalCompletionStatus === 'IN_PROGRESS'",
])
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
@@ -225,7 +226,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch ='))
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified/)
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
@@ -427,7 +428,7 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera
assert.equal((loading.match(/tasks\?loadingPlanId=/g) || []).length >= 7, true)
})
test('phase3 loading proves stale inventory zero-write for all items and completion audit', () => {
test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => {
const loading = source('./phase3-loading.mjs')
assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/)
assertOrder(loading, [
@@ -435,9 +436,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
'库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放保持待人工确认',
'人工确认柜次 PDA 装柜完成',
'回查原子 Batch 完成审计',
'人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计',
'回查下游门禁前原子 Batch 审计',
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
'订舱拖车报关未齐备时 LOADED 硬门禁拒绝',
'LOADED 硬门禁拒绝后 task/plan/inbound 零写入',
])
})
@@ -540,7 +542,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy
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.match(entry, /phase3Loading: loadingFixture,\s*\n\s*phase3LoadingIncremental: incrementalFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
assert.equal(run.marker.includes('-LOAD'), false)
})
@@ -54,7 +54,7 @@ export async function run(context) {
Boolean(customerId) && Boolean(customerName),
`customerId=${customerId || 'missing'}, customerName=${customerName || 'missing'}`, OPTIONS)
if (!customerId || !customerName) return
const linkContainer = (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED')
const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)
&& id(containerFlow?.tenantId) === customerId
&& Boolean(containerFlow?.containerBookingId)
&& Boolean(containerFlow?.loadingPlanId)
@@ -104,12 +104,6 @@ export async function run(context) {
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return
context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
marker: context.marker, finalStatus: 'COMPLETED',
}
const path = `${TRUCK}/${appointmentId}`
let version = Number(appointment.version)
for (const [name, endpoint, status] of [
@@ -146,4 +140,12 @@ export async function run(context) {
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
OPTIONS)
if (final?.status === 'COMPLETED') {
context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id,
containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null,
loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null,
marker: context.marker, finalStatus: 'COMPLETED',
}
}
}