diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 91fa0303d..49f40a477 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -987,11 +987,15 @@ export class AcceptanceRun { } } - async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') { + async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] + const createdKeys = [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) @@ -1009,6 +1013,45 @@ export class AcceptanceRun { await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this)) + const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey + const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { + const matrix = this.createdResources.phase4InboundProducerFixtures + const records = [matrix?.containers, matrix?.byDestinationType?.FBA, + matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) + for (const values of records) { + const target = values.find((item) => item?.resourceKey === producerKey) + if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId }) + } + } + if (producerKey) writeShippingEvidence(false) + if (pickupRequired && !producerKey) { + this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false, + 'phase3LoadingCompletedProducer.resourceKey=missing', + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + if (pickupRequired && producerKey) { + const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url)) + await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this)) + const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url)) + await this.executeModule(`service-center-flow-phase4-${label}`, () => serviceCenter.run(this)) + const portSchedules = await import(new URL('./admin-test-env-modules/phase3-port-schedules.mjs', import.meta.url)) + await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this)) + const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url)) + await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this)) + const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url)) + await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this)) + const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url)) + await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`, + () => carrierNodes.runUntilPickupReady(this)) + const pickup = this.createdResources.phase4CarrierPickupReady + const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true + && Boolean(pickup?.queryId) + writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null) + this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, + shippingReady, + `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } } finally { if (restoreFixture) restoreFixture() this.marker = matrixMarker @@ -1026,21 +1069,32 @@ export class AcceptanceRun { async ensurePhase4PdaProducerMatrix() { const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} if (Number(readiness().dropshipContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP') + await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true) } if (Number(readiness().fbaContainerCount || 0) < 2) { - await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA') + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false) } if (Number(readiness().overseasContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true) } if (Number(readiness().distinctContainerCount || 0) < 4) { - await this.runPhase4ProducerVariant('SECONDARY', 'FBA') + await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } const matrix = this.createdResources.phase4InboundProducerFixtures + const containers = Array.isArray(matrix?.containers) ? matrix.containers : [] + const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') + const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY'] + .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`))) + const shippingMatrixReady = dropshipReady?.shippingReady === true + && Boolean(dropshipReady?.carrierScheduleQueryId) + && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) + && awaitingVariants.length >= 1 + && awaitingVariants.every((item) => item.shippingReady === false + && item.carrierScheduleQueryId == null) this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, - matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, - `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 6574c4e2c..32699b741 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -81,7 +81,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') - assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) + assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) @@ -90,3 +90,35 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) assert.match(pda, /skuFixtures\(context, producer\)/) }) + +test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { + const entry = read('../admin-test-env-acceptance.mjs') + assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + const start = entry.indexOf('if (pickupRequired && producerKey)') + const end = entry.indexOf('} finally {', start) + const pickupChain = entry.slice(start, end) + const ordered = [ + 'phase3-customs.mjs', 'service-center-flow.mjs', 'phase3-port-schedules.mjs', + 'phase3-outbound-dispatch.mjs', 'phase3-carrier-schedule-queries.mjs', + 'phase4-carrier-nodes.mjs', 'runUntilPickupReady(this)', + ] + let cursor = -1 + for (const token of ordered) { + const next = pickupChain.indexOf(token) + assert.ok(next > cursor, `${token} must follow the real prerequisite chain`) + cursor = next + } + for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', + 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`)) + assert.match(pickupChain, /shippingReady/) + assert.match(entry, /carrierScheduleQueryId/) + assert.match(pickupChain, /pickup\?\.marker === variantMarker/) + assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) + assert.match(entry, /dropshipReady\?\.shippingReady === true/) + assert.match(entry, /overseasReady\?\.shippingReady === true/) + assert.match(entry, /awaitingVariants\.length >= 1/) + assert.doesNotMatch(entry, /awaitingVariants\.length === 2/) + assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) +}) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 7405b5832..dc99c6d41 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,14 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) + const start = source.indexOf('const createdKeys = [', source.indexOf('async runPhase4ProducerVariant')) + const end = source.indexOf(']', start) + const createdKeys = source.slice(start, end) + for (const key of [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ]) assert.match(createdKeys, new RegExp(`'${key}'`)) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { 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 6b0e61040..ee95227c6 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 @@ -55,6 +55,18 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/) assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) + assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) + assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) + assert.match(source, /跨区消费生成尾程订单/) + assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) + assert.match(source, /missingPickupReady=/) + assert.match(source, /item\?\.shippingReady === false/) + assert.match(source, /!expectedSet\.has\(id\(item\?\.containerNo\)\)/) + assert.match(source, /unexpectedAwaiting=/) + assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/) + assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/) + assert.match(source, /unexpectedHistorical=/) + assert.doesNotMatch(source, /awaitProducerCandidates/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e7e7040c1..b1f177e91 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -205,22 +205,63 @@ async function uploadPhotos(context, token) { return photos } -async function awaitProducerCandidates(context, token, producers) { +async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return true + const consumed = new Set() + const lastResponses = new Map() + for (let attempt = 1; attempt <= 30; attempt += 1) { + const missing = expected.filter((containerNo) => !consumed.has(containerNo)) + const results = await Promise.all(missing.map(async (containerNo) => { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + return { containerNo, 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) + } + } + if (expected.every((containerNo) => consumed.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + for (const containerNo of expected) { + const response = lastResponses.get(containerNo) || null + context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response, + consumed.has(containerNo), + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS) + } + return expected.every((containerNo) => consumed.has(containerNo)) +} + +async function awaitPickupReadyCandidates(context, token, producers, allProducers) { + 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)) if (!expected.length) return false let response = null let seen = new Set() for (let attempt = 1; attempt <= 30; attempt += 1) { response = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) - const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] - seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) - if (expected.every((containerNo) => seen.has(containerNo))) break + const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo)) + && awaiting.every((containerNo) => !seen.has(containerNo))) break await new Promise((resolve) => setTimeout(resolve, 1000)) } - const ready = expected.every((containerNo) => seen.has(containerNo)) - context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, - `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + const seenExpected = expected.filter((containerNo) => seen.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo)) + const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo)) + const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo)) + const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 + context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, + `expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS) return ready } @@ -501,9 +542,14 @@ export async function run(context) { const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers - if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { - blockerGap(context, '海外待收柜生产矩阵', - '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) { + blockerGap(context, '跨区消费生产矩阵', + '三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈') + return + } + if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { + blockerGap(context, '实际处理柜PICKUP_READY门禁', + 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') return } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 05baae375..6bfe64c05 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -80,8 +80,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) } assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) - assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) - assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runUntilPickupReady\(this\)/) + assert.match(source, /carrierScheduleQueryId/) assert.match(source, /readyForPhase4Pda === true/) assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) })