From 4f9433c39ae61cbc972b4a94e91dc634544ebe3f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 8 Oct 2026 21:36:53 +0800 Subject: [PATCH] test(pda): bind receiving fixtures to exact source container booking --- .../phase4-overseas-pda-contract.test.mjs | 97 ++++++++++++++++++- .../phase4-overseas-pda.mjs | 78 +++++++++------ 2 files changed, 144 insertions(+), 31 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index d90c64c69..a48675076 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -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=[] diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 79d650690..1b6327edc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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