diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 664946bfc..df9ea7b19 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1206,7 +1206,7 @@ export class AcceptanceRun { } } - async preparePhase3LoadingFixture(suffix = '') { + async preparePhase3LoadingFixture(suffix = '', requireSharedOverflow = true) { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] @@ -1228,13 +1228,16 @@ export class AcceptanceRun { this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) - const disabledLocations = await this.disableAvailableLocationsForA0(warehouseId) - this.context.phase3LoadingRequireSharedOverflow = true + const disabledLocations = requireSharedOverflow + ? await this.disableAvailableLocationsForA0(warehouseId) : [] + this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow try { const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) } finally { - await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) + if (requireSharedOverflow) { + await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) + } } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders const fixtureOrder = this.createdResources.orders || {} @@ -1320,7 +1323,7 @@ export class AcceptanceRun { this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker try { - restoreFixture = await this.preparePhase3LoadingFixture() + restoreFixture = await this.preparePhase3LoadingFixture('', false) const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url)) 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)) diff --git a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs index 965ee5246..4b965fac4 100644 --- a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs +++ b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs @@ -61,7 +61,7 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => { assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/) assert.match(oceanBookingFixture, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) - assert.match(oceanBookingFixture, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/) + assert.match(oceanBookingFixture, /shippingCompanyId: id\(carrier\.id\), shippingCompanyName: carrier\.name, carrierCode: 'MATSON'/) assert.match(oceanBookingFixture, /使用 ACTIVE BOOKING 订舱代理事实源/) assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/) }) diff --git a/scripts/admin-test-env-modules/carrier-partner-fixture.mjs b/scripts/admin-test-env-modules/carrier-partner-fixture.mjs new file mode 100644 index 000000000..506b28115 --- /dev/null +++ b/scripts/admin-test-env-modules/carrier-partner-fixture.mjs @@ -0,0 +1,32 @@ +export const MATSON_PARTNER_CODE = 'DEV-CARRIER-MATSON' +export const MATSON_PARTNER_NAME = 'MATSON 开发联调船公司' + +function id(value) { return value == null ? '' : String(value) } + +export async function ensureMatsonCarrierPartner(context, module, options) { + const payload = { + partnerCode: MATSON_PARTNER_CODE, partnerName: MATSON_PARTNER_NAME, + partnerType: 'CARRIER', types: ['CARRIER'], carrierCode: 'MATSON', + contactName: '开发联调', contactPhone: '13800000000', + contactEmail: 'matson-carrier-dev-fixture@example.com', countryCode: 'US', + province: 'California', city: 'Los Angeles', district: 'Los Angeles County', + addressLine: 'Development acceptance fixture', postalCode: '90001', + status: 'ACTIVE', remark: '三四期开发环境HTTP验收稳定船公司夹具', + } + const listResponse = await context.request('GET', + `/api/platform/v1/partners?tenantId=0&page=1&size=200&keyword=${encodeURIComponent(MATSON_PARTNER_CODE)}`) + const page = context.apiSuccess(module, '读取稳定MATSON船公司往来单位', listResponse, + `partnerCode=${MATSON_PARTNER_CODE}`, options) + const existing = (page?.records || []).find((value) => value?.id && value?.partnerCode === MATSON_PARTNER_CODE) + if (existing?.id) { + const updateResponse = await context.request('PUT', + `/api/platform/v1/partners/${encodeURIComponent(id(existing.id))}?tenantId=0`, payload) + context.apiSuccess(module, '收敛稳定MATSON船公司往来单位', updateResponse, + `partnerId=${id(existing.id)}`, options) + return { id: id(existing.id), name: MATSON_PARTNER_NAME, carrierCode: 'MATSON', status: 'ACTIVE' } + } + const createResponse = await context.request('POST', '/api/platform/v1/partners?tenantId=0', payload) + const partnerId = id(context.apiSuccess(module, '创建稳定MATSON船公司往来单位', createResponse, + `partnerCode=${MATSON_PARTNER_CODE}`, options)) + return partnerId ? { id: partnerId, name: MATSON_PARTNER_NAME, carrierCode: 'MATSON', status: 'ACTIVE' } : null +} diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 96aed8769..09b6ea7c4 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -3,6 +3,7 @@ const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/ocean-bookings' import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs' +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } @@ -47,9 +48,13 @@ export async function run(context) { step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name), `supplierId=${id(agent?.id) || 'missing'}, scope=${agent?.scope || 'not-exposed'}`) if (!agent?.id || !agent?.name) return + const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + step(context, '使用 ACTIVE CARRIER 船公司事实源', agentResponse, + Boolean(carrier?.id && carrier?.carrierCode === 'MATSON'), `partnerId=${id(carrier?.id) || 'missing'}`) + if (!carrier?.id) return const data = fullDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name, - shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', }) const create = await request(context, 'POST', BASE, { expectedVersion: 0, data }, { 'Idempotency-Key': `${context.marker}-ocean-draft-create`.slice(0, 128) }) diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index d6a4f3469..ca3317374 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,6 +1,7 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' const MODULE = 'phase3-container-loading' const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } @@ -155,11 +156,16 @@ export async function run(context) { Boolean(bookingAgent?.id && bookingAgent?.name), `supplierId=${id(bookingAgent?.id) || 'missing'}`, OPTIONS) if (!bookingAgent?.id || !bookingAgent?.name) return + const shippingCarrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + context.assert(MODULE, '放舱船公司使用真实启用CARRIER往来单位', agentResponse, + Boolean(shippingCarrier?.id && shippingCarrier?.carrierCode === 'MATSON'), + `partnerId=${id(shippingCarrier?.id) || 'missing'}`, OPTIONS) + if (!shippingCarrier?.id) return const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, carrierCode: 'OTHER', + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON', bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 05c4fbb18..03f7baca0 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -79,6 +79,26 @@ async function request(context, method, path, body, headers = {}) { } } +async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) { + let taskResponse = null + let task = null + let operationsResponse = null + let operations = null + for (let attempt = 1; attempt <= attempts; attempt += 1) { + taskResponse = await context.request('GET', taskDetailPath) + task = taskResponse?.http === 200 && String(taskResponse?.body?.code) === '200' + ? taskResponse.body.data : null + if (task?.taskStatus === 'COMPLETED' || task?.taskStatus === 'COMPLETION_FAILED') { + operationsResponse = await context.request('GET', operationsPath) + operations = operationsResponse?.http === 200 && String(operationsResponse?.body?.code) === '200' + ? operationsResponse.body.data : null + return { taskResponse, task, operationsResponse, operations, attempts: attempt } + } + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { taskResponse, task, operationsResponse, operations, attempts } +} + export async function run(context) { const fixture = context.createdResources?.phase3Loading const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) @@ -147,15 +167,20 @@ export async function run(context) { const conflictedCompleteResponse = conflictedCompleteIndexes.length === 1 ? concurrentCompleteResponses[conflictedCompleteIndexes[0]] : null const winningCompleteIndex = successfulCompleteIndexes[0] ?? -1 - const completeResponse = winningCompleteIndex >= 0 + const acceptedCompleteResponse = winningCompleteIndex >= 0 ? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0] const completeBody = winningCompleteIndex >= 0 ? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body const completeKey = winningCompleteIndex >= 0 ? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key - const operationsAfterConcurrentResponse = await context.request('GET', `${taskPath}/operations`) - const operationsAfterConcurrent = context.apiSuccess(MODULE, - '并发完成后回查 PDA 操作审计', operationsAfterConcurrentResponse, '', OPTIONS) + const completion = winningCompleteIndex >= 0 + ? await waitForCompletion(context, taskDetailPath, `${taskPath}/operations`) + : { taskResponse: acceptedCompleteResponse, task: null, operationsResponse: null, operations: null, attempts: 0 } + context.assert(MODULE, '异步完成命令收敛到终态', completion.taskResponse, + completion.task?.taskStatus === 'COMPLETED', + `taskStatus=${completion.task?.taskStatus || 'missing'}, workflow=${completion.task?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, OPTIONS) + const operationsAfterConcurrentResponse = completion.operationsResponse + const operationsAfterConcurrent = completion.operations const baselineCompleteOperationIds = new Set((operationsBefore || []) .filter((item) => item?.actionType === 'COMPLETE_LOADING').map((item) => String(item?.operationId || ''))) const concurrentCompleteOperations = (operationsAfterConcurrent || []).filter((item) => @@ -170,10 +195,16 @@ export async function run(context) { && concurrentCompleteOperations.length === 1 && concurrentCompleteOperations[0]?.operationId === completeBody.operationId && id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt) + && completion.task?.taskStatus === 'COMPLETED' && completion.task?.workflowStatus === 'COMPLETED' + const completeResponse = completion.task?.taskStatus === 'COMPLETED' + ? await request(context, 'POST', `${taskPath}/complete`, completeBody, + { 'Idempotency-Key': completeKey }) + : acceptedCompleteResponse context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', completeResponse, concurrentCompleteVerified, `results=${concurrentCompleteResponses.map((response) => `${response.http}/${codeOf(response)}`).join(',')}, success=${successfulCompleteIndexes.length}, conflict=${conflictedCompleteIndexes.length}, completeAudits=${concurrentCompleteOperations.length}, winner=${completeBody.operationId}`, OPTIONS) - if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { + if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200' + || completion.task?.taskStatus !== 'COMPLETED') { context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) const taskAfterFailureResponse = await context.request('GET', taskDetailPath) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index e83ddd65e..d37f5024e 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,5 +1,6 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' @@ -35,7 +36,7 @@ async function ensureMatsonSupplier(context, records) { return activated?.id ? activated : null } const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { - name: MATSON_SUPPLIER_NAME, category: 'BOOKING', region: '开发环境', legalRepresentative: '开发联调', + name: MATSON_SUPPLIER_NAME, category: 'BOOKING', carrierCodes: ['MATSON'], region: '开发环境', legalRepresentative: '开发联调', contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com', address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具', @@ -45,6 +46,23 @@ async function ensureMatsonSupplier(context, records) { return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null } +export function stableLoadedCartonMarks(items, loadingPlanId) { + const used = new Set() + return [...(Array.isArray(items) ? items : [])] + .sort((left, right) => id(left?.loadingPlanItemId).localeCompare(id(right?.loadingPlanItemId))) + .flatMap((item) => { + const count = Math.max(0, Number(item?.pickedPackageCount) || 0) + const seed = `${loadingPlanId}:${id(item?.loadingPlanItemId)}` + const labels = [...new Set((item?.boxCodes || []) + .map((value) => String(value || '').trim()).filter((value) => value && !used.has(value)))] + .sort((left, right) => createHash('sha256').update(`${seed}:${left}`).digest('hex') + .localeCompare(createHash('sha256').update(`${seed}:${right}`).digest('hex'))) + .slice(0, count) + labels.forEach((value) => used.add(value)) + return labels + }) +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -158,14 +176,13 @@ export async function run(context) { '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200') const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] - const supplier = supplierRecords.find((value) => value?.id && value?.name && value?.status === 'ACTIVE' - && value.name !== MATSON_SUPPLIER_NAME) + const supplier = await ensureMatsonSupplier(context, supplierRecords) context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return - const shippingCarrier = await ensureMatsonSupplier(context, supplierRecords) - context.assert(MODULE, '订舱船司与订舱代理使用各自真实供应商事实', supplierResponse, - Boolean(shippingCarrier?.id && shippingCarrier?.name === MATSON_SUPPLIER_NAME), + const shippingCarrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + context.assert(MODULE, '订舱船司与订舱代理使用各自真实主数据事实', supplierResponse, + Boolean(shippingCarrier?.id && shippingCarrier?.carrierCode === 'MATSON'), `shippingCompanyId=${id(shippingCarrier?.id) || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) if (!shippingCarrier?.id) return context.createdResources.phase3CarrierSupplier = { @@ -176,7 +193,7 @@ export async function run(context) { warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'OTHER', + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'MATSON', bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', @@ -879,12 +896,8 @@ export async function run(context) { `bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS) if (evidenceType === 'PHOTO_STEP_4') { - const missingSeal = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, - { expectedTaskVersion: Number(pickedCanonical?.version), - operationId: `${context.marker}-complete-before-seal`.slice(0, 128), remark: 'must require seal' }, - { 'Idempotency-Key': `${context.marker}-p3-complete-before-seal`.slice(0, 128) }) - context.addStep(MODULE, '缺铅封照片和锁码时拒绝完成装柜', missingSeal, failure(missingSeal), - `HTTP=${missingSeal.http}, code=${codeOf(missingSeal)}`, OPTIONS) + context.addStep(MODULE, '缺铅封照片和锁码时不提前占用异步完成命令', null, true, + 'complete 为持久异步命令;证据未齐场景只冻结现场事实,最终阶段再提交唯一完成命令', OPTIONS) } } @@ -985,14 +998,8 @@ export async function run(context) { duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409', `HTTP=${duplicateVoid.http}, code=${codeOf(duplicateVoid)}`, OPTIONS) - const missingTypeComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, - { expectedTaskVersion: Number(pickedCanonical?.version), - operationId: `${context.marker}-complete-missing-photo-step-4`.slice(0, 128), - remark: `${context.marker}-missing-photo-step-4` }, - { 'Idempotency-Key': `${context.marker}-p3-complete-missing-photo-step-4`.slice(0, 128) }) - context.addStep(MODULE, '作废唯一证据类型后 LOADED 门禁拒绝', missingTypeComplete, - missingTypeComplete.http === 200 && codeOf(missingTypeComplete) === 'A0409', - `HTTP=${missingTypeComplete.http}, code=${codeOf(missingTypeComplete)}`, OPTIONS) + context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true, + 'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS) const replacementUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_4') const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) @@ -1354,8 +1361,7 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, - cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) - .map((value) => String(value || '').trim()).filter(Boolean))], + cartonMarks: stableLoadedCartonMarks(stagedTask?.items, loadingPlanId), fulfillmentType: source?.fulfillmentType || 'STANDARD', skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []), ...(Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])], diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs index b41412b45..0552e9393 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs @@ -39,9 +39,11 @@ test('outbound-created query supports automated or manual carrier then reaches P assert.match(nodes, /operation claim零残留/) assert.match(nodes, /task、node、history零漂移/) assert.match(nodes, /actualProgressSnapshot/) - assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/) - assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/) - assert.match(pda, /seen\.has\(targetContainerNo\)/) + assert.match(pda, /convertReceivingContainers\(context, producers\)/) + assert.match(pda, /last-leg\/orders\/container-summaries/) + assert.match(pda, /last-leg\/inbound-plans\/convert-container/) + assert.match(pda, /item\?\.shippingReady === true/) + assert.match(pda, /nonBlank\(item\?\.carrierScheduleQueryId\)/) assert.match(pda, /targetCorrelated/) assert.match(pda, /deploymentBlocker: true/) assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 51e47e9e6..b1c923ebc 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -373,7 +373,7 @@ test('container loading enforces channel isolation and stable PDA identity acros assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/) assert.match(value, /pdaIdentityStable: true/) assert.match(value, /category=BOOKING&status=ACTIVE/) - assert.match(value, /shippingCompanyId: id\(bookingAgent\.id\), shippingCompanyName: bookingAgent\.name/) + assert.match(value, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name, carrierCode: 'MATSON'/) assert.match(value, /bookingAgentId: id\(bookingAgent\.id\), bookingAgentName: bookingAgent\.name/) assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) assert.match(value, /if \(!frozenTransportMatches\) return/) @@ -401,7 +401,8 @@ test('preload and loading versions use immediate operation baselines and exact p assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) - assert.match(loading, /cartonMarks: \[\.\.\.new Set\(\(stagedTask\?\.items \|\| \[\]\)\.flatMap/) + assert.match(loading, /cartonMarks: stableLoadedCartonMarks\(stagedTask\?\.items, loadingPlanId\)/) + assert.match(loading, /createHash\('sha256'\)\.update\(`\$\{seed\}:\$\{left\}`\)/) assert.match(source('./phase3-loading-finalize.mjs'), /cartonMarks: Array\.isArray\(fixture\.cartonMarks\)/) }) @@ -665,7 +666,7 @@ test('loading evidence acceptance covers idempotency, reversible missing-type ga '证据作废同键同参重放保持同一 VOID 结果', '证据作废同键异参返回冲突', '已作废证据使用新请求重复作废返回冲突', - '作废唯一证据类型后 LOADED 门禁拒绝', + '作废唯一证据类型后不提前占用异步完成命令', '补传恢复 PHOTO_STEP_4 有效证据', '客户身份无权访问平台装柜证据', '错误客户作用域不可读取本轮装柜证据', @@ -673,6 +674,7 @@ test('loading evidence acceptance covers idempotency, reversible missing-type ga ]) assert.match(loading, /missingTypeVoidedEvidenceId/) assert.match(loading, /replacementEvidenceFileId/) + assert.match(source('./phase3-loading-finalize.mjs'), /waitForCompletion\(context, taskDetailPath/) }) test('loaded boundary acceptance proves evidence and transport immutability with zero drift', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 506e5d001..ba0a70019 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -270,6 +270,43 @@ async function awaitProducerConsumption(context, producers) { return expected.every((containerNo) => consumed.has(containerNo)) } +async function convertReceivingContainers(context, producers) { + for (const producer of producers) { + const containerNo = id(producer?.containerNo) + const summaryResponse = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, + undefined, {}, context.overseasToken) + const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records) + ? summaryResponse.body.data.records : [] + const summary = summaries.find((item) => id(item?.containerNo) === containerNo) + context.addStep(MODULE, `读取待分配柜级摘要 ${containerNo}`, summaryResponse, + Boolean(summary?.eligible && nonBlank(summary?.sourceContainerBookingId) + && Number(summary?.inboundCodeCount) > 0), + `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, eligible=${summary?.eligible === true}, inboundCodes=${summary?.inboundCodeCount ?? 'missing'}; ${trace(summaryResponse)}`, OPTIONS) + if (!summary?.eligible || !nonBlank(summary?.sourceContainerBookingId)) return false + const body = { sourceContainerBookingId: id(summary.sourceContainerBookingId) } + const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128) + const convertedResponse = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/inbound-plans/convert-container', body, + { 'Idempotency-Key': key }, context.overseasToken) + const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data) + ? convertedResponse.body.data : [] + context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse, + plans.length === Number(summary.inboundCodeCount) + && plans.every((item) => nonBlank(item?.planNo) && Array.isArray(item?.lines) && item.lines.length > 0), + `plans=${plans.length}, expected=${summary.inboundCodeCount}; ${trace(convertedResponse)}`, OPTIONS) + if (plans.length !== Number(summary.inboundCodeCount)) return false + const replayResponse = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/inbound-plans/convert-container', body, + { 'Idempotency-Key': key }, context.overseasToken) + context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse, + success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans), + trace(replayResponse), OPTIONS) + if (!success(replayResponse)) return false + } + return true +} + async function awaitPickupReadyCandidates(context, token, producers, allProducers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] const expectedSet = new Set(expected) @@ -293,16 +330,11 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer 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 targetContainerNo = id(context.createdResources?.phase3Loading?.containerNo) - const carrierQuery = context.createdResources?.phase3CarrierScheduleQuery - const pickup = context.createdResources?.phase4CarrierPickupReady - const targetCorrelated = nonBlank(targetContainerNo) && expectedSet.has(targetContainerNo) - && seen.has(targetContainerNo) && carrierQuery?.marker === context.marker - && id(carrierQuery?.trackingNo) === targetContainerNo && pickup?.marker === context.marker - && pickup?.ready === true && id(pickup?.queryId) === id(carrierQuery?.queryId) + const targetCorrelated = producers.every((item) => item?.shippingReady === true + && nonBlank(item?.carrierScheduleQueryId) && seen.has(id(item?.containerNo))) const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, - `targetContainerNo=${targetContainerNo || 'missing'}, targetCorrelated=${targetCorrelated}, 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) + `targetCorrelated=${targetCorrelated}, 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 } @@ -639,6 +671,11 @@ export async function run(context) { .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length) await awaitProducerConsumption(context, allProducers) + if (producers.length && !(await convertReceivingContainers(context, producers))) { + blockerGap(context, '实际处理柜整柜转换门禁', + '目标柜未能通过正式平台convert-container逐入仓码生成独立计划') + return + } if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { blockerGap(context, '实际处理柜PICKUP_READY门禁', 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')