feat: close preload confirmation and loading flow
This commit is contained in:
@@ -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'/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user