test: release bookings before opening PDA

This commit is contained in:
2026-08-14 22:21:24 +08:00
parent 010098c4af
commit b2a093b31b
5 changed files with 112 additions and 48 deletions
@@ -342,10 +342,65 @@ 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)
if (!confirmedTaskAbsent) return
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128),
voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
estimatedArrivalAt: arrivalAt, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱五项运输字段放舱并满足 PDA 订舱门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, 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 pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
const pushed = context.apiSuccess(MODULE, '推送预装计划', pushResponse, '', OPTIONS)
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)
@@ -359,12 +414,6 @@ export async function run(context) {
&& Number(pushReplay?.version) === Number(pushed.version),
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
const gatedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无真实柜号时计划可排柜但 PDA 任务不开放', gatedTaskResponse,
failure(gatedTaskResponse) || !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id),
`HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, 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)
@@ -372,38 +421,6 @@ export async function run(context) {
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
`records=${records(preloadPage).length}`, OPTIONS)
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),
`containerNo=${transported?.containerNo || 'missing'}`, OPTIONS)
const confirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse,
failure(confirmedTaskResponse) || !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id),
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128),
voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
estimatedArrivalAt: arrivalAt, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱放舱并开放现场 PDA 任务', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS)
@@ -74,11 +74,13 @@ test('existing container number replacement keeps container plan and PDA identit
})
test('PDA task opens only after preload push, real container number and ocean release converge', () => {
assert.match(containerLoadingAcceptance, /无真实柜号时计划可排柜但 PDA 任务不开放/)
assert.match(containerLoadingAcceptance, /无真实柜号拒绝推送预装计划/)
assert.match(containerLoadingAcceptance, /无真实柜号推送失败后不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/)
assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱放舱并开放现场 PDA 任务/)
assert.match(containerLoadingAcceptance, /订舱五项运输字段放舱并满足 PDA 订舱门禁/)
assert.match(containerLoadingAcceptance, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
})
@@ -274,10 +274,34 @@ export async function run(context) {
`containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS)
if (transported?.containerNo !== containerNo) 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
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(bookingConfirmed?.oceanBooking?.version),
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), vesselName: `P3VESSEL-${suffix}`.slice(0, 128),
voyageNo: `P3V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
estimatedArrivalAt: arrivalAt, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) })
const bookingReleased = context.apiSuccess(MODULE, '三期订舱补齐五项运输字段并放舱', releaseResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse,
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, 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, '绑定真实柜号后推送柜次计划并生成 PDA 任务', pushResponse, '', OPTIONS)
const pushedLoadingPlan = context.apiSuccess(MODULE, '真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务', pushResponse, '', OPTIONS)
context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse,
pushedLoadingPlan?.status === 'EDITING'
&& Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1,
@@ -90,7 +90,10 @@ test('bridge loading starts through a real PDA batch before preload confirmation
'无真实柜号拒绝推送柜次计划',
'无真实柜号推送失败后不生成 PDA 任务',
'绑定真实柜号后开放三期 PDA 推送门禁',
'绑定真实柜号后推送柜次计划并生成 PDA 任务',
'订舱 CONFIRMED 即使已绑定真实柜号也不生成 PDA 任务',
'三期订舱补齐五项运输字段并放舱',
'三期订舱 RELEASED 后保持模拟柜关联身份',
'真实柜号与 RELEASED 订舱就绪后推送并生成 PDA 任务',
"pushedLoadingPlan?.status === 'EDITING'",
"claimedPlan?.status === 'LOADING'",
"const canonicalPick = await request",
@@ -107,6 +110,8 @@ test('bridge loading starts through a real PDA batch before preload confirmation
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
assert.match(value, /version: transportVersion/)
assert.match(value, /Number\.isInteger\(transportVersion\)/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => {
@@ -178,7 +183,10 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport"))
assert.match(value, /在放舱前绑定本轮真实箱号/)
assert.match(value, /订舱放舱并开放现场 PDA 任务/)
assert.match(value, /订舱五项运输字段放舱并满足 PDA 订舱门禁/)
assert.match(value, /无真实柜号拒绝推送预装计划/)
assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/)
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
assert.match(value, /POST', CONTAINERS/)
})
@@ -251,10 +259,12 @@ test('container loading enforces channel isolation and stable PDA identity acros
'两个真实有效渠道之间拒绝跨渠道装柜',
'人工分配本轮候选货物',
'首次关联真实已确认订舱柜',
'无真实柜号时计划可排柜但 PDA 任务不开放',
'无真实柜号拒绝推送预装计划',
'无真实柜号推送失败后不生成 PDA 任务',
'在放舱前绑定本轮真实箱号',
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
'订舱放舱并开放现场 PDA 任务',
'订舱五项运输字段放舱并满足 PDA 订舱门禁',
'柜号与 RELEASED 订舱就绪后推送预装计划',
'换柜号前读取 PDA 稳定库存快照',
'PDA 任务创建后替换真实柜号',
'PDA 任务创建后清空真实柜号失败',