test: confirm plans before PDA acceptance

This commit is contained in:
2026-08-15 17:40:01 +08:00
parent 05921eca13
commit 28daf15082
6 changed files with 172 additions and 8 deletions
@@ -21,6 +21,19 @@ test('retains all clickable container, loading-list and PDA workflow states thro
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`')
const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex)
const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex)
const canonicalTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', unconfirmedTaskIndex + 1)
assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex
&& canonicalTaskIndex > confirmIndex)
assert.match(source, /计划确认前不生成 PDA 任务/)
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/)
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
assert.match(source, /readiness\?\.readyForPda === true/)
assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/)
assert.match(source, /计划确认同键同参精确重放/)
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/)
assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(source, /bookingAgentId: null, bookingAgentName: null/)
@@ -185,10 +185,57 @@ async function createPlanTask(context, label) {
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`,
{ expectedVersion: Number(plan.version) }, `${label}-push`)
plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS)
const planId = id(plan?.id)
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`)
context.addStep(MODULE, `${label} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && String(unconfirmedTaskResponse?.body?.code) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${unconfirmedTaskResponse?.body?.code ?? 'missing'}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS)
const confirmableResponse = await context.request('GET',
`${LOADING}/loading-plans/${planId}?warehouseId=${source.warehouseId}`)
const confirmable = context.apiSuccess(MODULE, `${label} 读取计划级确认即时版本`, confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, `${label} 整计划允许确认且 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) {
throw new Error(`${label} 计划尚未满足整计划确认门禁`)
}
const confirmPath = `${LOADING}/loading-plans/${planId}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({
'Idempotency-Key': operationId(context, `${label}-plan-confirm`),
})
const confirmResponse = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, `${label} 计划级确认并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, `${label} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse,
id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId
&& 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') {
throw new Error(`${label} 计划确认未生成唯一 PDA 任务`)
}
const confirmReplay = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders)
context.assert(MODULE, `${label} 计划确认同键同参精确重放`, confirmReplay,
confirmReplay.http === 200 && String(confirmReplay?.body?.code) === '200'
&& id(confirmReplay?.body?.data?.readiness?.pdaTaskId) === confirmedTaskId
&& Number(confirmReplay?.body?.data?.version) === Number(confirmed.version),
`HTTP=${confirmReplay.http}, code=${confirmReplay?.body?.code ?? 'missing'}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`)
`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`)
const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS)
if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`)
if (id(task?.id) !== confirmedTaskId || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成或身份漂移`)
return { source, containerId, containerNo, plan, task }
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8')
test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => {
assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/)
assert.match(source, /桥接 pair 重放不改关联身份、状态或双侧版本/)
assert.doesNotMatch(source, /桥接同幂等键不同版本请求拒绝且不产生部分更新/)
})
test('bridge replay comparison freezes stable identity status and both versions', () => {
const link = {
id: '1', tenantId: '10', inboundId: '20', preloadPlanId: '30', preloadStatus: 'PENDING',
preloadVersion: 3, loadingPlanId: '40', containerBookingId: '50', containerNo: null,
loadingPlanStatus: 'EDITING', loadingPlanVersion: 4, allocatedPackageCount: 8,
linkedAt: '2026-08-15T09:00:00', ignoredTransportField: 'not-part-of-the-link-contract',
}
assert.deepEqual(stableLinkSnapshot({ ...link, ignoredTransportField: 'changed' }), stableLinkSnapshot(link))
assert.notDeepEqual(stableLinkSnapshot({ ...link, loadingPlanVersion: 5 }), stableLinkSnapshot(link))
assert.notDeepEqual(stableLinkSnapshot({ ...link, preloadVersion: 4 }), stableLinkSnapshot(link))
assert.notDeepEqual(stableLinkSnapshot({ ...link, allocatedPackageCount: 7 }), stableLinkSnapshot(link))
})
@@ -2,7 +2,19 @@ const MODULE = 'phase3-loading-model-bridge'
const OPTIONS = { scenario: 'parallel-loading-models', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') }
export function stableLinkSnapshot(value) {
return {
id: id(value?.id), tenantId: id(value?.tenantId), inboundId: id(value?.inboundId),
preloadPlanId: id(value?.preloadPlanId), preloadStatus: id(value?.preloadStatus),
preloadVersion: Number(value?.preloadVersion), loadingPlanId: id(value?.loadingPlanId),
containerBookingId: id(value?.containerBookingId), containerNo: id(value?.containerNo),
loadingPlanStatus: id(value?.loadingPlanStatus), loadingPlanVersion: Number(value?.loadingPlanVersion),
allocatedPackageCount: Number(value?.allocatedPackageCount), linkedAt: id(value?.linkedAt),
}
}
function sameStableLink(left, right) {
return JSON.stringify(stableLinkSnapshot(left)) === JSON.stringify(stableLinkSnapshot(right))
}
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, { method,
@@ -37,10 +49,25 @@ export async function run(context) {
context.assert(MODULE, '柜次侧反查到同一预装计划', planResponse,
Array.isArray(planLinks) && planLinks.some((item) => id(item.inboundId) === inboundId
&& id(item.preloadPlanId) === id(fixture.preloadPlanId)), `links=${planLinks?.length ?? 'n/a'}`, OPTIONS)
const forged = await request(context, 'POST',
const pairReplay = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedPreloadVersion: 0, expectedLoadingPlanVersion: 0 },
{ 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
context.addStep(MODULE, '桥接同幂等键不同版本请求拒绝且不产生部分更新', forged, failure(forged),
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
const replayedLink = pairReplay?.body?.data
context.addStep(MODULE, '已由推送建立的同一桥接 pair 重放返回原关联', pairReplay,
pairReplay.http === 200 && codeOf(pairReplay) === '200' && sameStableLink(replayedLink, link),
`HTTP=${pairReplay.http}, code=${codeOf(pairReplay)}, linkId=${id(replayedLink?.id) || 'missing'}`, OPTIONS)
const afterInboundResponse = await request(context, 'GET', inboundPath)
const afterInboundLinks = context.apiSuccess(MODULE, '桥接 pair 重放后回查入库侧关联', afterInboundResponse, '', OPTIONS)
const afterInboundLink = Array.isArray(afterInboundLinks)
? afterInboundLinks.find((item) => id(item.loadingPlanId) === loadingPlanId) : null
const afterPlanResponse = await request(context, 'GET', `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}/preloads`)
const afterPlanLinks = context.apiSuccess(MODULE, '桥接 pair 重放后回查柜次侧关联', afterPlanResponse, '', OPTIONS)
const afterPlanLink = Array.isArray(afterPlanLinks)
? afterPlanLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, '桥接 pair 重放不改关联身份、状态或双侧版本', afterPlanResponse,
sameStableLink(afterInboundLink, link) && sameStableLink(afterPlanLink, link),
`linkId=${id(link?.id)}, preloadVersion=${link?.preloadVersion}, loadingPlanVersion=${link?.loadingPlanVersion}`,
OPTIONS)
}
@@ -43,12 +43,23 @@ test('PDA opens only after a real container number and uses stable plan identity
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
const releaseIndex = source.indexOf("`${bookingPath}/release`")
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`")
assert.ok(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex && taskIndex > pushIndex)
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /containerNo: realContainerNo/)
assert.match(source, /const transportStable = transported\?\.containerNo === realContainerNo/)
assert.match(source, /transported\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
assert.match(source, /计划确认前不生成 PDA 任务/)
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/)
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
assert.match(source, /readiness\?\.readyForPda === true/)
assert.match(source, /taskId === confirmedTaskId/)
assert.match(source, /计划确认同键同参精确重放/)
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /id\(task\?\.loadingPlanId\) === planId/)
assert.match(source, /id\(task\?\.containerBookingId\) === containerId/)
assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/)
@@ -276,13 +276,53 @@ async function loadOneFragment(context, state) {
pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return null
const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, `${sourceOrderNo} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse,
unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404',
`HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS)
const confirmableResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 计划级确认即时版本`, confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, `${sourceOrderNo} 放舱后整计划允许确认且尚未就绪 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 null
const confirmPath = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`
const confirmBody = Object.freeze({ expectedVersion: confirmableVersion })
const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${operationBase}-plan-confirm`.slice(0, 128) })
const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
const confirmed = context.apiSuccess(MODULE, `计划级确认 ${sourceOrderNo} 并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS)
const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId)
context.assert(MODULE, `${sourceOrderNo} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse,
id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId
&& 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 null
const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders)
context.assert(MODULE, `${sourceOrderNo} 计划确认同键同参精确重放`, 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 taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS)
const taskId = id(task?.id)
const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id))
context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse,
Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
&& task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
&& Number(pickItem?.pickedPackageCount) === 0,
`taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)