test(pda): bind receiving fixtures to exact source container booking

This commit is contained in:
2026-10-08 21:36:53 +08:00
parent 6b6d217774
commit 4f9433c39a
2 changed files with 144 additions and 31 deletions
@@ -7,7 +7,102 @@ import { fileURLToPath } from 'node:url'
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer,
crossContainerCartonProbe, normalizeReceivingEvidence, receivingEvidenceValid,
receivingCompletionProof, captureReceivingEvidence, receivingEventRecoveryInventoryUnchanged,
probeCartonResolveNegatives, awaitReceivingCandidate } from './phase4-overseas-pda.mjs'
probeCartonResolveNegatives, awaitReceivingCandidate, convertReceivingContainers,
awaitProducerConsumption, awaitPickupReadyCandidates } from './phase4-overseas-pda.mjs'
function conversionContext({ existing = false, eta = null, wrongIdentity = false } = {}) {
const bookingId = '2108183323464437762', historicalId = '2099376270288678913'
const producer = { containerNo: 'TSTU1800264', containerBookingId: bookingId,
resourceKey: 'THIS-RUN-P4-FBA-SECOND:FBA:TSTU1800264' }
const summary = { containerNo: producer.containerNo, sourceContainerBookingId: wrongIdentity ? historicalId : bookingId,
totalOrderCount: 1, inboundCodeCount: 1, missingInboundCodeCount: 0,
planCount: existing ? 1 : 0, pendingPlanCount: existing ? 0 : 1, allPlansReady: existing }
const historical = { ...summary, sourceContainerBookingId: historicalId, planCount: 1, pendingPlanCount: 0, allPlansReady: true }
const plans = [{ lines: [{ inboundCode: 'THIS-RUN-INBOUND' }], pallets: [{ palletNo: 'PLT-THIS-RUN', warehousePalletId: '88' }] }]
const calls = [], steps = []
let converted = existing
const context = { marker: 'THIS-RUN', token: 'offline', calls, steps,
createdResources: { phase4OverseasPda: { generatedPalletsByInbound: {} }, phase4TailOrderControls: { generatedPlans: plans } },
addStep: (_module, name, _response, passed) => steps.push({ name, passed }),
assert: (_module, name, _response, passed) => steps.push({ name, passed }),
request: async (method, path, body, _token, headers) => {
calls.push({ method, path, body, headers })
let data
if (path.includes('/container-summaries?')) data = { records: [historical, summary] }
else if (path.endsWith(`/container-summaries/${bookingId}`)) data = { summary: converted ? { ...summary, planCount: 1, pendingPlanCount: 0, allPlansReady: true } : summary }
else if (path.endsWith('/container-arrival-times/query')) data = [{ sourceContainerBookingId: body.sourceContainerBookingIds[0], estimatedWarehouseArrivalAt: eta, version: 0 }]
else if (path.endsWith('/expected-arrival')) {
if (existing || path.includes(historicalId)) return { http: 200, body: { code: 'A0409', message: '该柜已生成入库计划,预计到仓时间不可修改' } }
data = { sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: body.estimatedWarehouseArrivalAt, version: 1 }
} else if (path.includes('/container-preview?')) data = { sourceContainerBookingId: bookingId, ready: true, totalOrderCount: 1,
pendingOrderCount: existing ? 0 : 1, existingPlannedPalletCount: existing ? 1 : 0, snapshotVersion: 1, snapshotHash: 'snapshot' }
else if (path.endsWith('/convert-container')) { converted = true; data = plans }
else throw Error(`Unexpected offline request: ${method} ${path}`)
return { http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' }
} }
return { context, producer, bookingId, historicalId }
}
test('r1 same-number historical booking cannot replace the current producer during ETA and plan conversion', async () => {
const { context, producer, bookingId, historicalId } = conversionContext()
assert.equal(await convertReceivingContainers(context, [producer]), true)
assert.ok(context.steps.every(step => step.passed))
assert.equal(context.calls[0].path, `/api/platform/v1/last-leg/orders/container-summaries/${bookingId}`)
assert.ok(context.calls.every(call => !call.path.includes(historicalId) && !call.path.includes('container-summaries?')))
assert.deepEqual(context.calls.filter(call => call.path.endsWith('/convert-container')).map(call => call.body.sourceContainerBookingId), [bookingId, bookingId])
assert.equal(context.calls.filter(call => call.method === 'PUT').length, 1)
})
test('existing current-run plans reuse their ETA without writes; missing locked ETA blocks rather than repairs', async () => {
for (const eta of ['2026-10-10T10:00:00', null]) {
const { context, producer } = conversionContext({ existing: true, eta })
assert.equal(await convertReceivingContainers(context, [producer]), Boolean(eta))
assert.equal(context.calls.filter(call => call.method === 'PUT' || call.path.endsWith('/convert-container')).length, 0)
assert.equal(context.steps.every(step => step.passed), Boolean(eta))
}
})
test('missing producer booking or mismatched detail identity fails before any ETA query or write', async () => {
for (const missing of [false, true]) {
const { context, producer } = conversionContext({ wrongIdentity: true })
if (missing) delete producer.containerBookingId
assert.equal(await convertReceivingContainers(context, [producer]), false)
assert.ok(context.steps.some(step => !step.passed))
assert.ok(context.calls.every(call => call.method === 'GET'))
}
})
test('producer consumption waits for exact booking projection; same-number history is never consumed', async () => {
const { context, producer, historicalId } = conversionContext()
const actualRequest = context.request
let attempt = 0
const delays = []
context.request = async (...args) => {
const result = await actualRequest(...args)
if (++attempt === 1) result.body.data.summary.sourceContainerBookingId = historicalId
else result.body.data.summary.sourceContainerBookingId = producer.containerBookingId
return result
}
assert.equal(await awaitProducerConsumption(context, [producer], { attempts: 2, delay: async ms => delays.push(ms) }), true)
assert.deepEqual(delays, [1000])
assert.equal(context.calls.length, 2)
assert.ok(context.calls.every(call => call.method === 'GET' && call.path.endsWith(`/${producer.containerBookingId}`)))
const wrong = conversionContext({ wrongIdentity: true })
assert.equal(await awaitProducerConsumption(wrong.context, [wrong.producer], { attempts: 1 }), false)
assert.equal(wrong.context.calls.length, 1)
})
test('pickup readiness ignores historical same-number rows in either order and cannot borrow their operable state', async () => {
const { producer, historicalId } = conversionContext()
const current = { containerNo: producer.containerNo, sourceContainerBookingId: producer.containerBookingId, operable: true }
const historical = { ...current, sourceContainerBookingId: historicalId, operable: false }
for (const rows of [[current, historical], [historical, current], [{ ...historical, operable: true }]]) {
const context = candidateContext([candidateResponse(rows)])
assert.equal(await awaitPickupReadyCandidates(context, 'offline', [producer], [producer], { attempts: 1 }), rows.length === 2)
assert.equal(context.calls.length, 1)
assert.equal(context.calls[0].method, 'GET')
}
})
function candidateContext(responses) {
const calls=[],steps=[]
@@ -480,48 +480,56 @@ export async function uploadPhotos(context, token) {
return photos
}
export async function awaitProducerConsumption(context, producers) {
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
export async function awaitProducerConsumption(context, producers, { attempts = 90, delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
const expected = [...new Map(producers.map(item => [id(item?.containerBookingId), item])).values()]
if (!expected.length) return true
if (expected.some(item => !longId(item?.containerBookingId) || !nonBlank(item?.containerNo))) return false
const consumed = new Set()
const lastResponses = new Map()
for (let attempt = 1; attempt <= 90; attempt += 1) {
const missing = expected.filter((containerNo) => !consumed.has(containerNo))
const results = await Promise.all(missing.map(async (containerNo) => {
const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
return { containerNo, response }
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const missing = expected.filter(item => !consumed.has(id(item.containerBookingId)))
const results = await Promise.all(missing.map(async (producer) => {
const response = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(producer.containerBookingId)}`)
return { producer, response }
}))
for (const { containerNo, response } of results) {
lastResponses.set(containerNo, response)
const rows = records(response?.body?.data)
if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) {
consumed.add(containerNo)
for (const { producer, response } of results) {
const bookingId = id(producer.containerBookingId)
lastResponses.set(bookingId, response)
const summary = response?.body?.data?.summary
if (success(response) && id(summary?.sourceContainerBookingId) === bookingId
&& summary?.containerNo === producer.containerNo && Number(summary?.totalOrderCount) > 0) {
consumed.add(bookingId)
}
}
if (expected.every((containerNo) => consumed.has(containerNo))) break
await new Promise((resolve) => setTimeout(resolve, 1000))
if (expected.every(item => consumed.has(id(item.containerBookingId)))) break
if (attempt < attempts) await delay(1000)
}
for (const containerNo of expected) {
const response = lastResponses.get(containerNo) || null
for (const producer of expected) {
const containerNo = producer.containerNo, bookingId = id(producer.containerBookingId)
const response = lastResponses.get(bookingId) || null
context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response,
true,
`containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS)
`containerNo=${containerNo}, sourceContainerBookingId=${bookingId}, consumed=${consumed.has(bookingId)}; ${trace(response)}`, OBSERVATION_OPTIONS)
}
return expected.every((containerNo) => consumed.has(containerNo))
return expected.every(item => consumed.has(id(item.containerBookingId)))
}
export async function convertReceivingContainers(context, producers) {
for (const producer of producers) {
const containerNo = id(producer?.containerNo)
const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {})
const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records)
? summaryResponse.body.data.records : []
const summary = summaries.find((item) => id(item?.containerNo) === containerNo)
const bookingId = id(producer?.containerBookingId)
if (!longId(bookingId) || !nonBlank(containerNo)) {
context.addStep(MODULE, '本轮生产柜次身份完整', null, false,
`containerNo=${containerNo || 'missing'}, sourceContainerBookingId=${bookingId || 'missing'}`, OPTIONS)
return false
}
const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}`, undefined, context.token, {})
const summary = success(summaryResponse) ? summaryResponse.body?.data?.summary : null
const totalOrders = Number(summary?.totalOrderCount)
const inboundCodes = Number(summary?.inboundCodeCount)
const existingPlans = Number(summary?.planCount)
const pendingPlans = Number(summary?.pendingPlanCount)
const convertible = nonBlank(summary?.sourceContainerBookingId)
const convertible = id(summary?.sourceContainerBookingId) === bookingId && summary?.containerNo === containerNo
&& totalOrders > 0 && inboundCodes === totalOrders
&& Number(summary?.missingInboundCodeCount) === 0
&& ((existingPlans === 0 && pendingPlans === totalOrders)
@@ -530,7 +538,6 @@ export async function convertReceivingContainers(context, producers) {
convertible,
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
if (!convertible) return false
const bookingId = id(summary.sourceContainerBookingId)
const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {})
const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data)
? arrivalQueryResponse.body.data : []
@@ -539,7 +546,12 @@ export async function convertReceivingContainers(context, producers) {
Boolean(arrivalTime) && Number.isInteger(Number(arrivalTime?.version)),
`sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalQueryResponse)}`,
OPTIONS)
if (!arrivalTime) return false
if (!arrivalTime || !Number.isInteger(Number(arrivalTime.version))) return false
if (existingPlans > 0 && !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
context.addStep(MODULE, `已生成计划的整柜预计到仓时间不可补写 ${containerNo}`, arrivalQueryResponse,
false, `sourceContainerBookingId=${bookingId}, plans=${existingPlans}, estimatedWarehouseArrivalAt=missing`, OPTIONS)
return false
}
if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) {
const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19)
const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version),
@@ -623,29 +635,35 @@ export async function convertReceivingContainers(context, producers) {
return true
}
async function awaitPickupReadyCandidates(context, token, producers, allProducers) {
export async function awaitPickupReadyCandidates(context, token, producers, allProducers,
{ attempts = 30, delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))]
const expectedSet = new Set(expected)
const awaiting = [...new Set(allProducers
.filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo)))
.map((item) => id(item?.containerNo)).filter(nonBlank))]
const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank))
const isKnownProducer = candidate => allProducers.some(producer => longId(producer?.containerBookingId)
&& id(candidate?.sourceContainerBookingId) === id(producer.containerBookingId)
&& candidate?.containerNo === producer.containerNo)
if (!expected.length) return false
let response = null
let candidatesByContainer = new Map()
for (let attempt = 1; attempt <= 30; attempt += 1) {
let unexpectedHistorical = []
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {})
const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : []
unexpectedHistorical = candidateRows.filter(item => !isKnownProducer(item))
.map(item => `${id(item?.containerNo)}:${id(item?.sourceContainerBookingId)}`)
candidatesByContainer = new Map(candidateRows
.filter(isKnownProducer)
.map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo)))
if (expected.every((containerNo) => candidatesByContainer.get(containerNo)?.operable === true)
&& awaiting.every((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)) break
await new Promise((resolve) => setTimeout(resolve, 1000))
if (attempt < attempts) await delay(1000)
}
const seenExpected = expected.filter((containerNo) => candidatesByContainer.has(containerNo))
const missingPickupReady = expected.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable !== true)
const unexpectedAwaiting = awaiting.filter((containerNo) => candidatesByContainer.get(containerNo)?.operable === true)
const unexpectedHistorical = [...candidatesByContainer.keys()].filter((containerNo) => !known.has(containerNo))
const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey)
&& item?.shippingReady !== false && candidatesByContainer.get(id(item?.containerNo))?.operable === true)
const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated