test(pda): bind receiving fixtures to exact source container booking
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user