feat: close preload confirmation and loading flow

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent 5b8e7d6989
commit a35794e918
34 changed files with 1194 additions and 512 deletions
+16
View File
@@ -53,6 +53,22 @@ export async function run(context) {
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
## WAIT_SPLIT 到 LOADED 冻结链
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 预装 EDITING 补集装箱号 -> 关联 CONFIRMED 订舱 -> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货/PDA complete -> 五类证据 -> 报关及下游门禁 -> loading complete -> LOADED`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;未补号、订舱未关联或未 `RELEASED`、整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `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`,不得回退历史资源。
部署后需要定向验证该完整链时,模块顺序固定为:
```powershell
node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize
```
该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。
## 消息中心模块
`notifications.mjs` 覆盖平台消息模板的查询、新增、编辑、版本冲突、激活与最终停用,以及未来定时消息任务的创建、幂等重放、列表查询、取消和非法重试。模块只创建未来定时任务并在结束前取消,不实际投递验收消息。定向 HTTP 验收需要当前对话明确授权:
@@ -233,6 +233,112 @@ export async function run(context) {
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
const pushKey = `${context.marker}-container-push`.slice(0, 128)
const pushRequest = Object.freeze({
path: `${SMART}/plans/${planId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assigned.version) }),
headers: Object.freeze({ 'Idempotency-Key': pushKey }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushed = context.apiSuccess(MODULE, '无真实柜号推送预装计划成功', pushResponse, '', OPTIONS)
context.assert(MODULE, '无号 push 仅冻结 EDITING 计划且版本递增', pushResponse,
id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null
&& Number(pushed?.version) > Number(assigned.version),
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS)
const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS)
const pushedLink = Array.isArray(pushedLinks)
? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null
context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse,
Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1,
`linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!pushedLink?.id) return
const pushedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse,
pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404',
responseEvidence(pushedTaskResponse), OPTIONS)
const pushBaseline = JSON.stringify({
plan: pushed, detail: pushedDetail, links: pushedLinks,
task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
context.apiSuccess(MODULE, '推送预装 exact replay', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const pushReplaySnapshot = JSON.stringify({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse,
pushBaseline === pushReplaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, replayBytes=${pushReplaySnapshot.length}`, OPTIONS)
const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushed.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody,
failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS)
const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
{ 'Idempotency-Key': `${context.marker}-container-push-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已推送计划使用新 key 重复 push 拒绝', pushNewKeyDuplicate,
failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse,
failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate)
&& pushBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${pushBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (pushBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
const realContainerNo = `TST-${suffix}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(pushed.version), operationReason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse,
transported?.summary?.containerNo === realContainerNo
&& id(transported?.summary?.planId) === planId
&& id(transported?.summary?.containerBookingId) === containerId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, containerBookingId=${id(transported?.summary?.containerBookingId) || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== realContainerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404'
context.addStep(MODULE, '已补号但未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS)
if (!unlinkedTaskAbsent) return
const transportedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const transportedContainer = context.apiSuccess(MODULE, '预装补号后读取模拟柜最新版本', transportedContainerResponse, '', OPTIONS)
if (!Number.isInteger(Number(transportedContainer?.version))) return
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
const bookingCreated = context.apiSuccess(MODULE, '登记含渠道仓库和运输快照的已确认真实订舱', bookingCreate, '', OPTIONS)
@@ -264,7 +370,7 @@ export async function run(context) {
const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType)
if (!requirement?.id || !wrongTypeRequirement?.id) return
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.version),
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(transportedContainer.version),
})
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
@@ -303,7 +409,7 @@ export async function run(context) {
const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(containerDetail.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(transportedContainer.version),
})
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
responseEvidence(linkResponse), OPTIONS)
@@ -334,41 +440,11 @@ export async function run(context) {
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
const missingContainerPushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) },
{ 'Idempotency-Key': `${context.marker}-container-push-without-container-no`.slice(0, 128) })
const missingContainerPushRejected = failure(missingContainerPushResponse)
context.addStep(MODULE, '无真实柜号拒绝推送预装计划', missingContainerPushResponse,
missingContainerPushRejected,
`HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerPushRejected) return
const gatedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const gatedTaskAbsent = failure(gatedTaskResponse)
|| !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id)
context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', gatedTaskResponse,
gatedTaskAbsent,
`HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!gatedTaskAbsent) return
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(linkedContainer.version), reason: context.marker,
})
const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS)
context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse,
transported?.containerNo === `TST-${suffix}`.slice(0, 64)
&& id(transported?.id) === containerId && transported?.cabinetNo === linkedContainer.cabinetNo,
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${planId}`, OPTIONS)
if (transported?.containerNo !== `TST-${suffix}`.slice(0, 64)) return
const confirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedTaskAbsent = failure(confirmedTaskResponse)
|| !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id)
context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent,
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404'
context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS)
if (!confirmedTaskAbsent) return
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
@@ -383,29 +459,6 @@ export async function run(context) {
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const pushKey = `${context.marker}-container-push`.slice(0, 128)
const pushRequest = Object.freeze({
path: `${SMART}/plans/${planId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assigned.version) }),
headers: Object.freeze({ 'Idempotency-Key': pushKey }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
&& Number(pushReplay?.version) === Number(pushed.version),
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -413,20 +466,84 @@ export async function run(context) {
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务', unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
responseEvidence(unconfirmedTaskResponse), OPTIONS)
const confirmableResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
&& confirmable?.summary?.readiness?.pdaTaskId == null,
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return
const confirmPath = `${FIRST_LEG}/loading-plans/${planId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({
'Idempotency-Key': `${context.marker}-container-plan-confirm`.slice(0, 128),
})
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmedPreload = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirmResponse, '', OPTIONS)
const confirmedTaskId = id(confirmedPreload?.readiness?.pdaTaskId)
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirmResponse,
id(confirmedPreload?.planId) === planId && id(confirmedPreload?.containerBookingId) === containerId
&& confirmedPreload?.confirmationStatus === 'CONFIRMED'
&& confirmedPreload?.readiness?.confirmationStatus === 'CONFIRMED'
&& confirmedPreload?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
&& Number(confirmedPreload?.version) > confirmableVersion,
`confirmationStatus=${confirmedPreload?.confirmationStatus || 'missing'}, readyForPda=${confirmedPreload?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
if (!confirmedTaskId || confirmedPreload?.confirmationStatus !== 'CONFIRMED') return
const confirmReplayResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplayResponse,
confirmReplayResponse.http === 200 && codeOf(confirmReplayResponse) === '200'
&& id(confirmReplayResponse.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplayResponse.body?.data?.version) === Number(confirmedPreload.version),
responseEvidence(confirmReplayResponse), OPTIONS)
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
{ expectedVersion: Number(confirmedPreload.version) }, confirmHeaders)
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
responseEvidence(confirmSameKeyDifferentBody), OPTIONS)
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
{ 'Idempotency-Key': `${context.marker}-container-plan-confirm-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
responseEvidence(confirmNewKeyDuplicate), OPTIONS)
const confirmedInboundResponse = await context.request('GET',
`/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`)
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
const confirmedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS)
context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse,
confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING'
&& Number(confirmedPlan?.version) === Number(confirmedPreload.version),
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId
Boolean(taskId) && taskId === confirmedTaskId && id(task?.loadingPlanId) === planId
&& id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo)
&& task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && task?.taskStatus === 'LOADING'
&& task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING'
&& task?.workflowStatus === 'UNASSIGNED'
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
if (!taskId || !pickItem?.id) return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const stableIdentity = {
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
cabinetNo: id(task.cabinetNo),
@@ -9,6 +9,7 @@ const overviewOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-c
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const overviewAcceptance = read('phase3-container-overview.mjs')
const oceanAcceptance = read('ocean-bookings.mjs')
const oceanFixture = read('ocean-booking-fixture.mjs')
const adminRoot = '../../../apps/admin-web/src/modules/'
const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`)
const releaseTypes = read(`${adminRoot}ocean-bookings/types.ts`)
@@ -28,7 +29,7 @@ test('registration freezes all five transport fields and release reuses the conf
assert.match(releaseTypes, new RegExp(`${field}: string`), field)
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
assert.match(releaseView, new RegExp(`detail\\.value\\.${field}`), field)
assert.match(oceanAcceptance, new RegExp(`${field}:`), field)
assert.match(oceanFixture, new RegExp(`${field}:`), field)
}
assert.match(releaseView, /当前订舱缺少已确认运输资料,无法放舱/)
assert.match(releaseView, /放舱不会重新采集或修改这些信息/)
@@ -84,14 +85,22 @@ test('existing container number replacement keeps container plan and PDA identit
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
})
test('PDA task opens only after preload push, real container number and ocean release converge', () => {
assert.match(containerLoadingAcceptance, /无真实柜号拒绝推送预装计划/)
assert.match(containerLoadingAcceptance, /无真实柜号推送失败后不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/)
test('PDA task opens only after no-number push, transport, release and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(containerLoadingAcceptance, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/)
assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/)
assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/)
assert.match(containerLoadingAcceptance, /计划级确认同 key 异参返回冲突/)
assert.match(containerLoadingAcceptance, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoadingAcceptance, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
})
+197 -148
View File
@@ -144,38 +144,6 @@ export async function run(context) {
&& simulation?.oceanBookingId == null,
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
if (!containerBookingId) return
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
const bookingCreated = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期订舱', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingCreated?.oceanBooking?.id)
if (!bookingId) return
const bookingConfirmed = bookingCreated
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
frozenTransportMatches,
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportMatches) return
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, requirementId=${id(requirement?.id) || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
if (!requirement?.id) return
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, '关联三期模拟柜与真实订舱', linkResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱关联保持内部柜次身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
@@ -204,93 +172,149 @@ export async function run(context) {
&& Number(assignedLoadingPlan?.version) === 1,
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0,
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
remark: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
const planId = id(smartData?.preloadPlan?.id)
const smartPreloadVersion = Number(smartData?.preloadPlan?.version)
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
&& Number.isInteger(smartPreloadVersion) && smartPreloadVersion >= 0
&& smartData?.preloadPlan?.items?.length === 1,
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id)
context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse,
Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)),
`preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS)
if (!planId) return
const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, items: [], remark: 'replay must not replace',
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay,
codeOf(replay) === '200' && id(replay.body?.data?.preloadPlan?.id) === planId
&& replay.body?.data?.preloadPlan?.items?.length === 1,
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
const bindVersions = Object.freeze({
expectedPreloadVersion: smartPreloadVersion,
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
const pushRequest = Object.freeze({
path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }),
headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }),
})
const bindResponse = await request(context, 'POST',
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
&& Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion
&& Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion,
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS)
context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse,
id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING'
&& pushedLoadingPlan?.containerNo == null
&& Number(pushedLoadingPlan?.version) > Number(assignedLoadingPlan.version),
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
if (pushedLoadingPlan?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS)
const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS)
const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse,
Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId
&& Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!link?.id) return
const missingContainerPushResponse = await request(context, 'POST',
`${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
{ expectedVersion: Number(link.loadingPlanVersion) },
{ 'Idempotency-Key': `${context.marker}-bridge-push-without-container-no`.slice(0, 128) })
const missingContainerPushRejected = failure(missingContainerPushResponse)
context.addStep(MODULE, '无真实柜号拒绝推送柜次计划', missingContainerPushResponse,
missingContainerPushRejected,
`HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerPushRejected) return
const missingContainerTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const missingContainerTaskAbsent = failure(missingContainerTaskResponse)
|| !id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id)
context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', missingContainerTaskResponse,
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse,
missingContainerTaskAbsent,
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, task=${id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerTaskAbsent) return
const containerBeforeTransportResponse = await context.request('GET',
`/api/platform/v1/containers/${containerBookingId}`)
const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次最新版本',
containerBeforeTransportResponse, '', OPTIONS)
const transportVersion = Number(containerBeforeTransport?.version)
context.assert(MODULE, '补真实柜号使用订舱关联后的最新柜次版本', containerBeforeTransportResponse,
id(containerBeforeTransport?.id) === containerBookingId && Number.isInteger(transportVersion),
`containerBookingId=${id(containerBeforeTransport?.id) || 'missing'}, version=${containerBeforeTransport?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(transportVersion)) return
const replayBaseline = JSON.stringify({
plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks,
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
})
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replaySnapshot = JSON.stringify({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse,
replayBaseline === replaySnapshot && codeOf(replayTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, replayBytes=${replaySnapshot.length}`, OPTIONS)
const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path,
{ expectedVersion: Number(pushedLoadingPlan.version) }, pushRequest.headers)
context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody,
failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS)
const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body,
{ 'Idempotency-Key': `${context.marker}-bridge-push-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已推送计划使用新 key 重复 push 返回冲突', newKeyDuplicate,
failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = JSON.stringify({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
failure(sameKeyDifferentBody) && failure(newKeyDuplicate)
&& replayBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404',
`baselineBytes=${replayBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS)
if (replayBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return
const transportResponse = await request(context, 'PATCH',
`/api/platform/v1/containers/${containerBookingId}/transport`, {
containerNo, voyageNo: `P3V-${suffix}`, version: transportVersion,
reason: `${context.marker}-bind-container-no`,
})
const transported = context.apiSuccess(MODULE, '绑定真实柜号后开放三期 PDA 推送门禁', transportResponse, '', OPTIONS)
context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse,
transported?.containerNo === containerNo && id(transported?.id) === containerBookingId
&& transported?.cabinetNo === linked.cabinetNo,
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (transported?.containerNo !== containerNo) return
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version),
operationReason: `${context.marker}-bind-container-no`,
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse,
transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId
&& id(transported?.summary?.containerBookingId) === containerBookingId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== containerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '已补号但未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404',
`HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS)
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft },
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, '创建带渠道仓库快照的三期 CONFIRMED 订舱', bookingCreateResponse, '', OPTIONS)
const bookingId = id(bookingConfirmed?.oceanBooking?.id)
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`)
const latestContainer = context.apiSuccess(MODULE, '补号后读取模拟柜最新版本用于订舱关联', latestContainerResponse, '', OPTIONS)
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(latestContainer?.version),
})
const linked = context.apiSuccess(MODULE, '预装补号后关联三期 CONFIRMED 订舱', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (id(linked?.id) !== containerBookingId) return
const confirmedBookingTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedBookingTaskAbsent = failure(confirmedBookingTaskResponse)
|| !id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id)
context.addStep(MODULE, '订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务', confirmedBookingTaskResponse,
confirmedBookingTaskAbsent,
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}, task=${id(confirmedBookingTaskResponse?.body?.id || confirmedBookingTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!confirmedBookingTaskAbsent) return
context.addStep(MODULE, '订舱已关联但未 RELEASED 仍无 PDA 任务', confirmedBookingTaskResponse,
confirmedBookingTaskResponse.http === 200 && codeOf(confirmedBookingTaskResponse) === 'A0404',
`HTTP=${confirmedBookingTaskResponse.http}, code=${codeOf(confirmedBookingTaskResponse)}`, OPTIONS)
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
@@ -301,24 +325,66 @@ export async function run(context) {
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, transportFrozen=${transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
{ expectedVersion: Number(link.loadingPlanVersion) },
{ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) })
const pushedLoadingPlan = context.apiSuccess(MODULE, '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse,
pushedLoadingPlan?.status === 'EDITING'
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const unconfirmedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务', unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}`, OPTIONS)
const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: initialVersion, remark: 'must confirm preload first' },
{ 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) })
context.addStep(MODULE, '预装未确认时拒绝完成装柜', blockedBeforePreloadConfirm,
failure(blockedBeforePreloadConfirm),
`HTTP=${blockedBeforePreloadConfirm.http}, code=${codeOf(blockedBeforePreloadConfirm)}`, OPTIONS)
const confirmableResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
&& confirmable?.summary?.readiness?.pdaTaskId == null,
`version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS)
if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return
const confirmPath = `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${context.marker}-p3-plan-confirm`.slice(0, 128) })
const confirm = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, '计划级原子确认整份预装并触发唯一 PDA 任务', confirm, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, '计划确认返回 CONFIRMED、readyForPda 与唯一 taskId', confirm,
id(confirmed?.planId) === loadingPlanId && id(confirmed?.containerBookingId) === containerBookingId
&& confirmed?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.confirmationStatus === 'CONFIRMED'
&& confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId)
&& Number(confirmed?.version) > confirmableVersion,
`confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS)
if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, '计划级确认同键同参精确重放同一 taskId 与版本', confirmReplay,
confirmReplay.http === 200 && codeOf(confirmReplay) === '200'
&& id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplay.body?.data?.version) === Number(confirmed.version),
`HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS)
const confirmSameKeyDifferentBody = await request(context, 'POST', confirmPath,
{ expectedVersion: Number(confirmed.version) }, confirmHeaders)
context.addStep(MODULE, '计划级确认同 key 异参返回冲突', confirmSameKeyDifferentBody,
confirmSameKeyDifferentBody.http === 200 && codeOf(confirmSameKeyDifferentBody) === 'A0409',
`HTTP=${confirmSameKeyDifferentBody.http}, code=${codeOf(confirmSameKeyDifferentBody)}, requestId=${confirmSameKeyDifferentBody.requestId || 'missing'}, traceId=${confirmSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const confirmNewKeyDuplicate = await request(context, 'POST', confirmPath, confirmBody,
{ 'Idempotency-Key': `${context.marker}-p3-plan-confirm-duplicate`.slice(0, 128) })
context.addStep(MODULE, '已确认计划使用新 key 重复确认返回冲突', confirmNewKeyDuplicate,
confirmNewKeyDuplicate.http === 200 && codeOf(confirmNewKeyDuplicate) === 'A0409',
`HTTP=${confirmNewKeyDuplicate.http}, code=${codeOf(confirmNewKeyDuplicate)}, requestId=${confirmNewKeyDuplicate.requestId || 'missing'}, traceId=${confirmNewKeyDuplicate.traceId || 'missing'}`, OPTIONS)
const confirmedInboundResponse = await context.request('GET', detailPath)
const confirmedInbound = context.apiSuccess(MODULE, '计划确认后 claim 前回查入库状态', confirmedInboundResponse, '', OPTIONS)
const confirmedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const confirmedPlan = context.apiSuccess(MODULE, '计划确认后 claim 前回查柜次计划', confirmedPlanResponse, '', OPTIONS)
context.assert(MODULE, '计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING', confirmedPlanResponse,
confirmedInbound?.summary?.processStatus === 'WAIT_SPLIT' && confirmedPlan?.status === 'EDITING'
&& Number(confirmedPlan?.version) === Number(confirmed.version),
`inbound=${confirmedInbound?.summary?.processStatus || 'missing'}, plan=${confirmedPlan?.status || 'missing'}`, OPTIONS)
if (confirmedInbound?.summary?.processStatus !== 'WAIT_SPLIT' || confirmedPlan?.status !== 'EDITING') return
const canonicalTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
@@ -326,11 +392,17 @@ export async function run(context) {
const canonicalTaskId = id(canonicalTask?.id)
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
Boolean(canonicalTaskId) && id(canonicalTask?.loadingPlanId) === loadingPlanId
Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId
&& id(canonicalTask?.containerBookingId) === containerBookingId
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
`taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
if (!canonicalTaskId || !canonicalItem?.id) return
const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS)
const beforeClaimPlanVersion = Number(beforeClaimPlan?.version)
if (!Number.isInteger(beforeClaimPlanVersion)) return
const claim = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/claim`, {
expectedAssignmentVersion: Number(canonicalTask?.assignment?.version || 0),
operationId: `${context.marker}-bridge-claim`.slice(0, 128),
@@ -343,7 +415,7 @@ export async function run(context) {
const claimedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const claimedPlan = context.apiSuccess(MODULE, '领取后回查柜次计划', claimedPlanResponse, '', OPTIONS)
context.assert(MODULE, '领取 PDA 任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushedLoadingPlan?.version) + 1,
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
const inventoryResponse = await context.request('GET',
@@ -607,34 +679,11 @@ export async function run(context) {
directPdaCompletion && pickedPlan?.status === 'COMPLETED',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS)
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
confirmingData?.preloadPlan?.status === 'CONFIRMING'
&& Number(confirmingData?.preloadPlan?.version) > Number(link.preloadVersion),
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
const confirm = await request(context, 'POST', `${base}/preload/confirm?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(confirmingData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirm`.slice(0, 128) })
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
context.assert(MODULE, directPdaCompletion
? '装柜已开始时预装确认保持 LOADING 和 CONFIRMING'
: '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
directPdaCompletion
? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING'
&& Number(confirmed?.preloadPlan?.version) === Number(confirmingData.preloadPlan.version)
: confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
&& Number(confirmed?.preloadPlan?.version) > Number(confirmingData.preloadPlan.version),
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
if (confirmed?.mainStatus !== 'LOADING') return
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
context.assert(MODULE, '预装清单归属、明细和 marker 持久化', preload,
context.assert(MODULE, 'PDA 完成后预装清单仍为 CONFIRMED 且归属稳定', preload,
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
&& preloadData?.items?.[0]?.containerCode === `P3-${suffix}` && preloadData?.remark === context.marker,
&& preloadData?.status === 'CONFIRMED',
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
const loadingResponse = await context.request('GET', detailPath)
@@ -83,47 +83,69 @@ test('container loading follows push, claim, real pick, completed sequence', ()
assert.match(value, /operationId: `\$\{context\.marker\}-pda-complete`\.slice\(0, 128\)/)
})
test('bridge loading starts through a real PDA batch before preload confirmation', () => {
test('bridge loading confirms preload before creating and claiming the PDA task', () => {
const value = source('./phase3-loading.mjs')
assertOrder(value, [
'原子关联入库预装与柜次装柜计划',
'无真实柜号拒绝推送柜次计划',
'无真实柜号推送失败后不生成 PDA 任务',
'绑定真实柜号后开放三期 PDA 推送门禁',
'订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务',
'无真实柜号推送预装成功并冻结柜次明细',
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'预装 EDITING 清单绑定真实柜号',
'已补号但未关联订舱仍无 PDA 任务',
'预装补号后关联三期 CONFIRMED 订舱',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'三期订舱 RELEASED 后保持模拟柜关联身份',
'真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务',
"pushedLoadingPlan?.status === 'EDITING'",
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
'读取柜次级 PDA 唯一事实任务',
"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\) === Number\(confirmingData\.preloadPlan\.version\)/)
assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
assert.match(value, /version: transportVersion/)
assert.match(value, /Number\.isInteger\(transportVersion\)/)
assert.match(value, /const confirmPath = `\/api\/platform\/v1\/first-leg\/loading-plans\/\$\{loadingPlanId\}\/confirm`/)
assert.match(value, /confirmed\?\.confirmationStatus === 'CONFIRMED'/)
assert.match(value, /confirmed\?\.readiness\?\.readyForPda === true/)
assert.match(value, /canonicalTaskId === confirmedTaskId/)
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /loading-plans\/\$\{loadingPlanId\}\/transport\?warehouseId=/)
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assertOrder(value, [
'CONFIRMED 订舱回显完整且冻结五项运输快照',
'if (!frozenTransportMatches) return',
'三期订舱携带已冻结运输快照并放舱',
])
assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/)
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => {
for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) {
const value = source(file)
assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/)
assert.match(value, /push exact replay[\s\S]*零漂移/)
assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/)
assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/)
assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/)
assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/)
}
})
test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => {
const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay'))
assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/)
assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/)
assert.doesNotMatch(push, /loadingPickService\.beginIfReady/)
assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/)
assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/)
})
test('finalize rechecks the same unified order detail and list as LOADED', () => {
const value = source('./phase3-loading-finalize.mjs')
assertOrder(value, [
@@ -176,6 +198,8 @@ test('container loading arranges a simulation before linking a confirmed real bo
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
"const assignResponse = await request",
'无真实柜号推送预装计划成功',
'预装 EDITING 清单绑定本轮真实柜号',
"const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
])
@@ -188,8 +212,7 @@ test('container loading arranges a simulation before linking a confirmed real bo
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /containerAllocations\.length === 0/)
assert.match(value, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.doesNotMatch(value, /version: Number\(containerDetail\?\.version \?\? 0\), reason: context\.marker/)
assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/)
@@ -206,11 +229,18 @@ test('container loading arranges a simulation before linking a confirmed real bo
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, /在放舱前绑定本轮真实箱号/)
assert.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId='))
assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /无真实柜号拒绝推送预装计划/)
assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(value, /confirmationStatus === 'CONFIRMED'/)
assert.match(value, /readiness\?\.readyForPda === true/)
assert.match(value, /taskId === confirmedTaskId/)
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
assert.match(value, /POST', CONTAINERS/)
})
@@ -283,13 +313,17 @@ test('container loading enforces channel isolation and stable PDA identity acros
'新增真实异渠道模拟柜',
'两个真实有效渠道之间拒绝跨渠道装柜',
'人工分配本轮候选货物',
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'预装 EDITING 清单绑定本轮真实柜号',
'已补号但未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'无真实柜号拒绝推送预装计划',
'无真实柜号推送失败后不生成 PDA 任务',
'在放舱前绑定本轮真实箱号',
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
'柜号与 RELEASED 订舱就绪后推送预装计划',
'订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
'计划确认后 task 待领取且 claim 前仍 WAIT_SPLIT/EDITING',
'换柜号前读取 PDA 稳定库存快照',
'PDA 任务创建后替换真实柜号',
'PDA 任务创建后清空真实柜号失败',
@@ -320,11 +354,10 @@ test('preload and loading versions use immediate operation baselines and exact p
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
const loading = source('./phase3-loading.mjs')
assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/)
assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/)
assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/)
assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/)
assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/)
assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/)
assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2)
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
})
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
+60 -17
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
const scriptsDir = dirname(fileURLToPath(import.meta.url))
const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8')
@@ -62,33 +63,75 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
})
test('test-environment loading chains bind the latest container version before opening PDA', () => {
test('test-environment loading chains push without a number and open PDA only after all prerequisites', () => {
const containerLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8')
const bridgeLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(containerLoading, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.match(containerLoading, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/)
const bridgeIndex = bridgeLoading.indexOf('原子关联入库预装与柜次装柜计划')
const rejectedPushIndex = bridgeLoading.indexOf('无真实柜号拒绝推送柜次计划')
const transportIndex = bridgeLoading.indexOf('绑定真实柜号后开放三期 PDA 推送门禁')
const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const transportIndex = bridgeLoading.indexOf('预装 EDITING 清单绑定真实柜号')
const linkIndex = bridgeLoading.indexOf('预装补号后关联三期 CONFIRMED 订舱')
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
const successfulPushIndex = bridgeLoading.indexOf('真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
assert.ok(bridgeIndex >= 0 && rejectedPushIndex > bridgeIndex && transportIndex > rejectedPushIndex
&& releaseIndex > transportIndex && successfulPushIndex > releaseIndex && pdaIndex > successfulPushIndex)
assert.match(bridgeLoading, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
assert.match(bridgeLoading, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
assert.match(bridgeLoading, /version: transportVersion/)
assert.ok(pushIndex >= 0 && absentIndex > pushIndex && transportIndex > absentIndex
&& linkIndex > transportIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/)
assert.match(bridgeLoading, /readiness\?\.readyForPda === true/)
assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerRejectedPushIndex = containerLoading.indexOf('无真实柜号拒绝推送预装计划')
const containerTransportIndex = containerLoading.indexOf('在放舱前绑定本轮真实箱号')
const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
const containerTransportIndex = containerLoading.indexOf('预装 EDITING 清单绑定本轮真实柜号')
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
const containerSuccessfulPushIndex = containerLoading.indexOf('柜号与 RELEASED 订舱就绪后推送预装计划')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
assert.ok(containerRejectedPushIndex >= 0 && containerTransportIndex > containerRejectedPushIndex
&& containerReleaseIndex > containerTransportIndex && containerSuccessfulPushIndex > containerReleaseIndex
&& containerPdaIndex > containerSuccessfulPushIndex)
assert.ok(containerPushIndex >= 0 && containerTransportIndex > containerPushIndex
&& containerLinkIndex > containerTransportIndex && containerReleaseIndex > containerLinkIndex
&& containerConfirmIndex > containerReleaseIndex
&& containerPdaIndex > containerConfirmIndex)
assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoading, /readiness\?\.readyForPda === true/)
assert.match(containerLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
})
test('default Admin HTTP entry carries one marker-isolated loading chain through evidence and LOADED', () => {
const focused = normalizeModuleOrder([
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs',
'phase3-loading-finalize',
])
assert.deepEqual(focused, [
'merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview',
'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs',
'phase3-loading-finalize',
])
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
const evidenceGate = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-evidence-gate.mjs'), 'utf8')
const customs = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-customs.mjs'), 'utf8')
const finalize = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading-finalize.mjs'), 'utf8')
assert.match(loading, /context\.createdResources\.phase3Loading = \{[\s\S]*marker: context\.marker/)
for (const field of ['inboundId', 'loadingPlanId', 'containerBookingId', 'canonicalPdaTaskId']) {
assert.match(loading, new RegExp(`context\\.createdResources\\.phase3Loading = \\{[\\s\\S]*${field}`))
}
assert.match(loading, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/)
assert.match(evidenceGate, /fixture\??\.marker !== context\.marker/)
assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/)
assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/)
assert.match(finalize, /fixture\?\.marker !== context\.marker/)
assert.match(finalize, /统一硬门禁通过后推进 LOADED 且五类有效证据仍完整/)
assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/)
})