This commit is contained in:
2026-08-17 15:55:11 +08:00
parent 5f79616623
commit 68d9216cb8
21 changed files with 121 additions and 85 deletions
+2 -2
View File
@@ -62,11 +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 -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> 拖车与报关清关门禁 -> PDA task complete 同事务完成 task/plan/inbound -> 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`。
计划在 `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`,不得回退历史资源。
计划在 `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` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车缺失或未完成不得阻断,若稳定柜次有关联有效拖车,则该业务动作负责将其推进到“已完成装柜”。当前后端 OpenAPI 仍写“预先 COMPLETED 拖车”,验收 finalize 已 `BLOCKED_CONTRACT` 主动停止,待新响应/历史/回滚字段冻结后接线。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
部署后需要定向验证该完整链时,模块顺序固定为:
@@ -85,7 +85,7 @@ export function selectCustomsFlow(createdResources) {
return { fixture: null, container: null }
}
async function ensureServiceCenterFlow(context, container) {
async function ensureReleasedOceanBooking(context, container) {
const containerBookingId = id(container?.containerBookingId)
const loadingPlanId = id(container?.loadingPlanId)
let ocean = context.createdResources?.oceanBooking
@@ -104,22 +104,7 @@ async function ensureServiceCenterFlow(context, container) {
return false
}
let truck = context.createdResources?.serviceCenterFlow
if (truck?.finalStatus !== 'COMPLETED'
|| id(truck?.oceanBookingId) !== id(ocean.id)
|| id(truck?.containerBookingId) !== containerBookingId
|| id(truck?.loadingPlanId) !== loadingPlanId) {
const serviceCenter = await import(new URL('./service-center-flow.mjs', import.meta.url))
await serviceCenter.run(context)
truck = context.createdResources?.serviceCenterFlow
}
const ready = truck?.finalStatus === 'COMPLETED'
&& id(truck?.oceanBookingId) === id(ocean.id)
&& id(truck?.containerBookingId) === containerBookingId
&& id(truck?.loadingPlanId) === loadingPlanId
context.addStep(MODULE, '报关前拖车已完成并绑定实际装柜柜次', null, ready,
`truckStatus=${truck?.finalStatus || 'missing'}, truckContainer=${id(truck?.containerBookingId) || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS)
return ready
return true
}
export async function run(context) {
@@ -137,7 +122,7 @@ export async function run(context) {
`tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
return
}
if (!await ensureServiceCenterFlow(context, container)) return
if (!await ensureReleasedOceanBooking(context, container)) return
const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
const slicesResponse = await context.request('GET', slicesPath)
@@ -73,32 +73,25 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi
assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/)
})
test('downstream modules keep the task pending until the transactional PDA final complete', () => {
test('downstream modules stop before HTTP until the new truck-coupled complete contract freezes', () => {
assert.match(convergence, /summary\?\.status === 'LOADING'/)
assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/)
assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/)
assert.match(customs, /ensureReleasedOceanBooking/)
assert.doesNotMatch(customs, /truck\?\.finalStatus !== 'COMPLETED'/)
ordered(finalize, [
'最终完成前拖车与报关清关门禁属于同一稳定柜次',
'最终完成前四元身份稳定且任务等待人工确认',
'读取最终完成并发前计划即时版本',
'Admin 增量与 PDA final complete 并发恰好一个事务成功',
'订舱拖车报关齐备后完成装柜',
'统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移',
'入库单终态 LOADED、版本递增且审计历史存在',
'统一硬门禁通过后五类有效证据仍完整',
'COMPLETED 后新增计划明细被拒绝',
'COMPLETED 后调整计划明细被拒绝',
'COMPLETED 后删除计划明细被拒绝',
'最终装柜前保持 LOADING 且版本有效',
'PDA complete 联动拖车并原子推进 LOADED',
'return',
'const taskPath =',
])
assert.match(finalize, /`\$\{taskPath\}\/complete`/)
assert.match(finalize, /await Promise\.all\(/)
assert.match(finalize, /completeWon !== adjustWon/)
assert.match(finalize, /BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断/)
assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/)
assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/)
})
test('PDA final completion OpenAPI freezes zero-write gate semantics', () => {
test('current PDA complete OpenAPI remains a blocker because it still requires pre-completed truck', () => {
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/)
assert.match(finalize, /BLOCKED_CONTRACT/)
})
@@ -69,14 +69,11 @@ export async function run(context) {
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
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)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA complete 联动拖车并原子推进 LOADED',
'BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断;等待后端冻结 complete 响应中的拖车投影、LOADING→COMPLETED 历史动作、错柜隔离、重放与失败回滚字段后再执行写 HTTP。当前脚本主动停止,禁止沿用“拖车必须预先 COMPLETED”的旧门禁。',
'contract')
return
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)
@@ -460,15 +460,16 @@ test('phase3 loading fixture forces A0 through reversible public location status
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
})
test('customs provisions released booking and completed truck for the selected loading container', () => {
test('customs provisions released booking without pre-completing truck', () => {
const value = source('./phase3-customs.mjs')
assertOrder(value, [
'selectCustomsFlow(context.createdResources)',
'if (!await ensureServiceCenterFlow(context, container)) return',
'if (!await ensureReleasedOceanBooking(context, container)) return',
'const slicesPath =',
])
assert.match(value, /oceanBookings\.run\(context\)/)
assert.match(value, /serviceCenter\.run\(context\)/)
assert.doesNotMatch(value, /serviceCenter\.run\(context\)/)
assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/)
assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(value, /category: 'CUSTOMS'/)
assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)