From a4a43061549dbf3543643fb4cb6a11bbd90a4856 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 03:32:35 +0800 Subject: [PATCH] test: align phase five dev acceptance flows --- .../ocean-booking-prototype-contract.test.mjs | 3 + .../admin-test-env-modules/ocean-bookings.mjs | 17 ++++- .../phase3-container-loading.mjs | 10 +-- .../phase3-loading-finalize.mjs | 28 +++++--- .../admin-test-env-modules/phase3-loading.mjs | 4 +- .../phase3-pda-convergence.mjs | 9 +-- .../phase3-port-schedules.mjs | 28 ++++---- .../phase3-producer-chain-contract.test.mjs | 18 +++-- .../phase4-carrier-nodes-contract.test.mjs | 5 ++ .../phase4-carrier-nodes.mjs | 5 +- .../phase4-overseas-pda-contract.test.mjs | 3 + .../phase4-overseas-pda.mjs | 7 +- .../service-center-flow.mjs | 72 ++++--------------- 13 files changed, 106 insertions(+), 103 deletions(-) diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs index 114d4e677..121c42169 100644 --- a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -31,6 +31,9 @@ test('DRAFT can be partial but release validates and freezes the five transport assert.match(fixture, new RegExp(`${field}:`), field) } assert.match(booking, /const frozen = transportSnapshot\(completedDraft\?\.oceanBooking \|\| data\)/) + assert.match(booking, /documentType', 'BOOKING_CONFIRMATION'/) + assert.match(booking, /\/documents\/upload/) + assert.match(booking, /expectedVersion: documentVersion/) assert.match(booking, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) assert.match(booking, /Object\.entries\(frozen\)\.every/) assert.match(booking, /RELEASED 编辑拒绝且零写入/) diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index caa8e2f43..14bada010 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -84,8 +84,23 @@ export async function run(context) { step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`) if (failed(complete)) return const frozen = transportSnapshot(completedDraft?.oceanBooking || data) + const documentForm = new FormData() + documentForm.append('expectedVersion', String(completeVersion)) + documentForm.append('documentType', 'BOOKING_CONFIRMATION') + documentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }), + `booking-confirmation-${suffix}.txt`) + const documentResponse = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, + documentForm, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-document`.slice(0, 128) }) + const document = context.apiSuccess(MODULE, '放舱前上传当前有效订舱确认文件', documentResponse, '', OPTIONS) + const documentVersion = Number(document?.bookingVersion) + context.assert(MODULE, '订舱确认文件归属当前订舱且递增版本', documentResponse, + Boolean(document?.document?.id) && id(document?.document?.oceanBookingId) === bookingId + && document?.document?.documentType === 'BOOKING_CONFIRMATION' && document?.document?.valid === true + && Number.isInteger(documentVersion) && documentVersion > completeVersion, + `documentId=${id(document?.document?.id) || 'missing'}, bookingVersion=${document?.bookingVersion ?? 'missing'}`, OPTIONS) + if (!Number.isInteger(documentVersion) || documentVersion <= completeVersion) return const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`, - { expectedVersion: completeVersion, reason: context.marker }, + { expectedVersion: documentVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) }) const released = payload(finalRelease) step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 24ea42302..de299f677 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -235,16 +235,16 @@ export async function run(context) { const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainerId = id(foreignContainer?.id) if (!foreignContainerId) return - const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { - bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, - }) - const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) + const foreignPlanResponse = await context.request('GET', + `${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`) + const foreignPlan = context.apiSuccess(MODULE, '读取异渠道柜次原子生成的智能装柜计划', + foreignPlanResponse, '', OPTIONS) const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), }) context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, - foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), + foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === 'A0422', `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 00128dc99..3c6de68e9 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -136,7 +136,6 @@ export async function run(context) { `status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS) if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return - const base = `/api/platform/v1/inbounds/${inboundId}` const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const taskBeforeResponse = await context.request('GET', taskDetailPath) @@ -316,7 +315,8 @@ export async function run(context) { Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED', `orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS) - const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}` + const evidenceBase = `${taskPath}/evidence` + const evidencePath = `${evidenceBase}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const immutableEvidenceResult = await waitForEvidenceSnapshot( context, evidencePath, fixture.evidenceTypes || []) const immutableEvidenceResponse = immutableEvidenceResult.response @@ -358,19 +358,27 @@ export async function run(context) { && Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId), `HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) - const postLoadedEvidence = await request(context, 'POST', evidencePath, { - expectedVersion: 0, evidenceType: 'PHOTO_STEP_2', fileId: fixture.postLoadedFileId, - fileName: 'client-must-not-override.png', sealNo: null, remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) }) + const postLoadedEvidenceForm = new FormData() + postLoadedEvidenceForm.append('file', new Blob([Uint8Array.from([ + 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137, + 0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130, + ])], { type: 'image/png' }), `${context.marker}-after-loaded.png`) + const postLoadedEvidenceQuery = new URLSearchParams({ + warehouseId: id(fixture.warehouseId), expectedVersion: String(fixture.immutableEvidenceVersion), + evidenceType: 'PHOTO_STEP_1', remark: `${context.marker}-after-loaded`, + }) + const postLoadedEvidence = await context.requestMultipart('POST', + `${evidenceBase}?${postLoadedEvidenceQuery}`, postLoadedEvidenceForm, context.token, + { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) }) context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence, postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409' && Boolean(postLoadedEvidence.requestId) && Boolean(postLoadedEvidence.traceId), `HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS) const postLoadedVoid = await request(context, 'POST', - `${base}/loading/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(fixture.immutableEvidenceVersion), reason: `${context.marker}-void-after-loaded` }, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) }) + `${evidenceBase}/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`, + { warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion), + reason: `${context.marker}-void-after-loaded` }) context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid, postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409' && Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId), @@ -378,7 +386,7 @@ export async function run(context) { const immutableTransport = await request(context, 'PATCH', `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`, - { containerNo: `MUTATE-${context.marker}`.slice(0, 64), voyageNo: 'MUTATE', + { containerNo: immutablePlan?.containerNo, voyageNo: 'MUTATE', expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` }, { 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) }) context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 3f7e53ada..8890a5ba4 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -330,8 +330,8 @@ export async function run(context) { const link = Array.isArray(synchronizedLinks) ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId) : null context.assert(MODULE, '分配自动建立唯一预装关联且身份稳定', synchronizedLinksResponse, Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId - && Number(link?.allocatedPackageCount) === 1, - `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS) + && Number(link?.allocatedPackageCount) === initialAssignmentCount, + `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}, allocatedPackages=${link?.allocatedPackageCount ?? 'missing'}, expectedPackages=${initialAssignmentCount}`, OPTIONS) if (!link?.id) return const synchronizedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 937804c87..23e0878d0 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -87,6 +87,7 @@ export async function run(context) { const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds) ? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : [] const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean) + const expectedBatchCount = fixture?.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? 1 : 4 context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -95,10 +96,10 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - expectedBatchOperationIds.length === 4 - && new Set(expectedBatchOperationIds).size === 4 - && actualBatchOperationIds.length === 4 - && new Set(actualBatchOperationIds).size === 4 + expectedBatchOperationIds.length === expectedBatchCount + && new Set(expectedBatchOperationIds).size === expectedBatchCount + && actualBatchOperationIds.length === expectedBatchCount + && new Set(actualBatchOperationIds).size === expectedBatchCount && expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId)) && actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)), `expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 823a79b81..4903c81b4 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -212,13 +212,13 @@ export async function run(context) { const inboundId = id(flow?.inboundId) if (knownAllocation && inboundId) { - const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` - const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) - const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS) - const scheduleGate = expiredGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') - context.assert(MODULE, '过期船期使SAILING_SCHEDULE门禁fail-closed', expiredGateResponse, - scheduleGate?.passed === false && scheduleGate?.actual === 'EXPIRED', - `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) + const expiredGateResponse = await request(context, 'GET', + `${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) + const expiredGate = context.apiSuccess(MODULE, '过期船期后读取当前国内出仓统一门禁', expiredGateResponse, '', OPTIONS) + const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) + context.assert(MODULE, '船期不再作为国内出仓硬门禁', expiredGateResponse, + Array.isArray(expiredGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), + `inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) } const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false) @@ -237,13 +237,13 @@ export async function run(context) { `expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) if (knownAllocation && inboundId) { - const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` - const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) - const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS) - const scheduleGate = readyGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') - context.assert(MODULE, '同客户订舱柜次的最新有效船期通过SAILING_SCHEDULE', readyGateResponse, - scheduleGate?.passed === true && String(scheduleGate?.actual || '').startsWith('READY:scheduleId='), - `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) + const readyGateResponse = await request(context, 'GET', + `${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) + const readyGate = context.apiSuccess(MODULE, '有效船期后重新读取国内出仓统一门禁', readyGateResponse, '', OPTIONS) + const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) + context.assert(MODULE, '船期有效性不改变国内出仓硬门禁集合', readyGateResponse, + Array.isArray(readyGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), + `inbounds=${readyGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) } const forged = await request(context, 'POST', BASE, { 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 4abe9c924..2db771bdc 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 @@ -367,6 +367,7 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch const value = source('./phase3-container-loading.mjs') assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/) assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/) + assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/) }) test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { @@ -460,10 +461,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) { assert.match(loading, new RegExp(key)) } - assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /expectedBatchOperationIds = dropshipWholePallet\s*\? \[batchKey\]\s*:\s*\[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) - assert.match(convergence, /expectedBatchOperationIds\.length === 4/) - assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/) + assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/) + assert.match(convergence, /expectedBatchOperationIds\.length === expectedBatchCount/) }) test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { @@ -591,7 +592,10 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(serviceCenter, /if \(!allocationMatches\) return/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) assert.match(serviceCenter, /oceanBookingId: ocean\.id/) - assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/) + assert.match(serviceCenter, /FORBIDDEN_TRUCK_FIELDS = \['customerId', 'customerName', 'containerBookingId', 'loadingPlanId'/) + assert.match(serviceCenter, /bookingLevel\(payload\)/) + assert.match(serviceCenter, /bookingLevel\(appointment\)/) + assert.doesNotMatch(serviceCenter, /containerBookingId:\s*linkContainer|loadingPlanId:\s*linkContainer/) assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /链路承运商-/) @@ -603,6 +607,10 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) + assert.match(schedules, /outbound-dispatches/) + assert.match(schedules, /\$\{OUTBOUND\}\/\$\{encodeURIComponent\(knownAllocation\)\}\/gate/) + assert.match(schedules, /item\?\.code !== 'SAILING_SCHEDULE'/) + assert.doesNotMatch(schedules, /\/check-gate\?|find\?\.\(\(item\) => item\?\.code === 'SAILING_SCHEDULE'/) }) test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => { @@ -728,6 +736,8 @@ test('loaded boundary acceptance proves evidence and transport immutability with assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/) assert.match(finalize, /afterHistorySignature === historySignature/) assert.match(finalize, /afterPlanSignature === immutablePlanSignature/) + assert.match(finalize, /warehouseId: id\(fixture\.warehouseId\)/) + assert.doesNotMatch(finalize, /warehouseId: Number\(fixture\.warehouseId\)/) }) test('finalize publishes a current-marker producer correlation for cross-region verification', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 7caafaa4e..b8aba6357 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -80,6 +80,11 @@ test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint' } }) +test('system-created carrier histories accept operator zero while user writes still require numeric identities', () => { + assert.match(source, /function validOperatorId\(value\) \{ return value === '0' \|\| validNumericId\(value\) \}/) + assert.match(source, /item\?\.operatorId == null \|\| validOperatorId\(item\.operatorId\)/) +}) + test('ownership requires current marker, long query id and outbound-confirm source', () => { const valid = { queryId: QUERY_ID, marker: MARKER, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM' } diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index f89fc08e3..99675ccae 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -18,6 +18,7 @@ function nullableText(value) { return value == null || typeof value === 'string' function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } +function validOperatorId(value) { return value === '0' || validNumericId(value) } function validContainerNo(value) { if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, @@ -88,7 +89,7 @@ function validHistory(item) { return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) - && nullableText(item?.reason) && (item?.operatorId == null || validNumericId(item.operatorId)) + && nullableText(item?.reason) && (item?.operatorId == null || validOperatorId(item.operatorId)) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } @@ -156,7 +157,7 @@ export async function runUntilPickupReady(context) { const queryId = id(owned.queryId) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) - assertDetail(context, detailResponse, detail, '详情返回Task、十节点DTO、异常与历史结构') + assertDetail(context, detailResponse, detail, '详情返回Task、已记录节点、异常与历史结构') const initialTask = detail?.query?.query const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo && id(initialTask?.oceanBookingId) === id(owned.oceanBookingId) 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 69ff2bf6d..decb79f1d 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 @@ -32,6 +32,7 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /同键同载荷重放/) assert.match(source, /同键异载荷冲突/) assert.match(source, /跨柜切换锁返回409/) + assert.match(source, /item\.operable === true && item\.allPlansReady === true/) assert.match(source, /第二仓库操作员抢占同柜返回409/) assert.match(source, /OVERSEAS_WAREHOUSE_PDA_OPERATOR/) assert.match(source, /lastleg:pda:operate/) @@ -99,6 +100,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /unexpectedAwaiting=/) assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/) assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/) + assert.match(source, /nonBlank\(item\?\.resourceKey\)/) + assert.match(source, /item\?\.shippingReady !== false/) assert.match(source, /unexpectedHistorical=/) assert.doesNotMatch(source, /awaitProducerCandidates/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 4d71110c8..645383d0a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -364,8 +364,8 @@ 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 targetCorrelated = producers.every((item) => item?.shippingReady === true - && nonBlank(item?.carrierScheduleQueryId) && seen.has(id(item?.containerNo))) + const targetCorrelated = producers.every((item) => nonBlank(item?.resourceKey) + && item?.shippingReady !== false && seen.has(id(item?.containerNo))) const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, `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) @@ -562,7 +562,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, { idempotencyKey: key(context, 'secondary-prepare') }, conflict) } - const other = candidates.data.find((item) => item.containerNo !== containerNo) + const other = candidates.data.find((item) => item.containerNo !== containerNo + && item.operable === true && item.allPlansReady === true && Number(item.totalPackages) > 0) if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 398a79d07..f5270e288 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -4,7 +4,9 @@ const OCEAN = '/api/platform/v1/ocean-bookings' const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } -function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +const FORBIDDEN_TRUCK_FIELDS = ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId', + 'cabinetNo', 'containerNo'] +function bookingLevel(value) { return value != null && FORBIDDEN_TRUCK_FIELDS.every((field) => !Object.hasOwn(value, field)) } async function actionRequest(context, path, endpoint, version) { try { @@ -30,32 +32,12 @@ async function actionRequest(context, path, endpoint, version) { } } -async function uploadBillOfLading(context, appointmentId) { - const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` - const form = new FormData() - const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) - form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`) - const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, - body: form, signal: AbortSignal.timeout(60_000), - }) - const body = await response.json() - return { method: 'POST', path, http: response.status, body, - requestId: body?.requestId || response.headers.get('x-request-id'), - traceId: body?.traceId || response.headers.get('x-trace-id') } -} - export async function run(context) { const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const ocean = containerFlow?.oceanBookingId ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } : context.createdResources?.oceanBooking - const containerFixture = [ - context.createdResources?.orders, - context.createdResources?.orchestrationFixtures?.inboundMeasurement, - context.createdResources?.orchestrationFixtures?.phase3Container, - ].find((item) => id(item?.inboundId) === id(containerFlow?.inboundId)) if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) @@ -65,14 +47,6 @@ export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10) const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`) const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) - const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus) - && Boolean(containerFlow?.containerBookingId) - && Boolean(containerFlow?.loadingPlanId) - if (!linkContainer) { - context.addStep(MODULE, '本轮已放舱订舱必须关联真实柜和装柜计划', oceanResponse, false, - `containerStatus=${containerFlow?.finalStatus || 'missing'}, containerBookingId=${id(containerFlow?.containerBookingId) || 'missing'}`, OPTIONS) - return - } const allocationMatches = oceanDetail?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, @@ -86,14 +60,9 @@ export async function run(context) { Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) if (!carrier) return const payload = { - outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, - oceanBookingId: ocean.id, - containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, - loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, - warehouseId: linkContainer - ? id(containerFlow.warehouseId) - : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), + outboundOrderId: null, oceanBookingId: ocean.id, warehouseId: id(oceanDetail?.oceanBooking?.warehouseId), carrierId: id(carrier.id), carrierName: carrier.name, + billOfLadingNo: oceanDetail?.oceanBooking?.billOfLadingNo, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', @@ -101,28 +70,18 @@ export async function run(context) { plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, currency: 'RMB', remark: context.marker, expectedVersion: 0, } - if (linkContainer) { - const missingContainerResponse = await context.request('POST', TRUCK, { - ...payload, containerBookingId: null, remark: `${context.marker}-missing-container`, - }) - context.addStep(MODULE, '拖车关联装柜计划时缺失柜次被拒绝', missingContainerResponse, - missingContainerResponse.http === 200 && codeOf(missingContainerResponse) !== '200', - `HTTP=${missingContainerResponse.http}, code=${codeOf(missingContainerResponse)}`, OPTIONS) - } + context.assert(MODULE, '跨服务拖车创建请求仅使用订舱级协议', oceanResponse, + bookingLevel(payload) && Boolean(payload.warehouseId && payload.billOfLadingNo), + `fields=${Object.keys(payload).join(',')}`, OPTIONS) const response = await context.request('POST', TRUCK, payload) - const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) + const appointment = context.apiSuccess(MODULE, '创建订舱级跨服务拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) const appointmentId = id(appointment?.id) - context.assert(MODULE, '拖车预约关联订舱、柜次、装柜计划、版本和审计字段完整', response, + context.assert(MODULE, '拖车预约关联订舱、版本和审计字段完整且无柜次协议', response, Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) - && (!linkContainer || id(appointment?.containerBookingId) === id(containerFlow.containerBookingId) - && id(appointment?.loadingPlanId) === id(containerFlow.loadingPlanId)) - && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), - `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) + && bookingLevel(appointment) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), + `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return const path = `${TRUCK}/${appointmentId}` - const billUpload = await uploadBillOfLading(context, appointmentId) - const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS) - if (!billAttachment?.id) return let version = Number(appointment.version) for (const [name, endpoint, status] of [ ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], @@ -144,9 +103,8 @@ export async function run(context) { const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) - && (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId) - && id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)), - `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) + && bookingLevel(final), + `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) @@ -161,8 +119,6 @@ export async function run(context) { if (final?.status === 'COMPLETED') { context.createdResources.serviceCenterFlow = { appointmentId, oceanBookingId: ocean.id, - containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, - loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, marker: context.marker, finalStatus: 'COMPLETED', } }