diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 89bfa0a4c..7e214efc1 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], @@ -22,9 +22,10 @@ const MODULE_DEPENDENCIES = new Map([ ['truck-appointments', ['merchant-qualification-fixture']], ['service-center-flow', ['ocean-bookings', 'truck-appointments']], ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], + ['phase3-container-documents', ['phase3-loading-finalize']], ['phase3-customs', ['phase3-loading-finalize']], ['phase3-port-schedules', ['ocean-bookings']], - ['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']], + ['phase3-outbound-dispatch', ['phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'service-center-flow']], ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], @@ -1188,7 +1189,7 @@ export class AcceptanceRun { const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = [ - 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking', 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', ] @@ -1213,6 +1214,8 @@ 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 documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url)) + await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this)) const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { const matrix = this.createdResources.phase4InboundProducerFixtures diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c4deabeb5..6f81b8808 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -49,7 +49,7 @@ export async function run(context) { | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | -| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | @@ -68,6 +68,8 @@ export async function run(context) { 四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 +节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 + ```powershell node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 05894ba14..83674f498 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -146,12 +146,7 @@ export function customsCaseFieldGaps(record) { export function outboundAggregateFieldGaps(record) { const aggregate = record?.aggregate ?? record - return gaps(aggregate, { - orderId: isPositiveId, - inboundId: isPositiveId, - inboundCode: isNonBlank, - customerId: isPositiveId, - customerName: isNonBlank, + const result = gaps(aggregate, { containerBookingId: isPositiveId, containerNo: isNonBlank, loadingPlanId: isPositiveId, @@ -162,6 +157,14 @@ export function outboundAggregateFieldGaps(record) { oceanBookingStatus: isNonBlank, dispatchStatus: isNonBlank, version: isNonNegativeNumber, - 'gate.items': (value) => Array.isArray(value), + inbounds: (value) => Array.isArray(value) && value.length > 0, + 'gate.inbounds': (value) => Array.isArray(value) && value.length > 0, }) + for (const [index, inbound] of (Array.isArray(aggregate?.inbounds) ? aggregate.inbounds : []).entries()) { + result.push(...gaps(inbound, { + orderId: isPositiveId, inboundId: isPositiveId, inboundCode: isNonBlank, + tenantId: isPositiveId, inboundStatus: isNonBlank, + }).map((field) => `inbounds[${index}].${field}`)) + } + return result } diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 33330e2b0..b1320a6d9 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -91,7 +91,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], - [outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'], + [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] for (const [validator, sample, options, missing] of samples) { assert.deepEqual(validator(sample, options), []) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index d9d46f29c..886f39387 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -92,6 +92,12 @@ export async function run(context) { const task = matches[0].query const queryId = id(task.id) + const automatedCarrier = AUTOMATED_CARRIERS.includes(task?.carrierCode) + const manualCarrier = task?.carrierCode === 'OTHER' || !AUTOMATED_CARRIERS.includes(task?.carrierCode) + context.assert(MODULE, '出仓自动建任务兼容自动船司与未知船司人工处理', pageResponse, + (automatedCarrier && ['READY', 'MANUAL_REQUIRED'].includes(task?.status)) + || (manualCarrier && task?.status === 'MANUAL_REQUIRED' && Boolean(task?.manualReason)), + `carrierCode=${task?.carrierCode || 'missing'}, status=${task?.status || 'missing'}, manualReason=${task?.manualReason || 'none'}`, OPTIONS) const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE' @@ -107,6 +113,7 @@ export async function run(context) { queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo, + carrierCode: task?.carrierCode, initialStatus: task?.status, version: Number(data(detailResponse)?.query?.query?.version), } } diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs new file mode 100644 index 000000000..24d896cd5 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { DOCUMENT_TYPES } from './phase3-container-documents.mjs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('four canonical container documents are frozen and use only public platform APIs', () => { + assert.deepEqual(DOCUMENT_TYPES, [ + 'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER', + ]) + for (const token of [ + '/clearance/document/upload', '/clearance/complete', '/customs/document/upload', '/customs/complete', + '/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key', + ]) assert.ok(source.includes(token), token) + assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/) +}) + +test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => { + for (const token of [ + 'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", 'deploymentBlocker: true', + '同键同载荷重放', '同键异文件返回A0409', '完成同键重放', '完成同键异载荷返回A0409', + '负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments', + ]) assert.ok(source.includes(token), token) +}) + +test('focused pickup-ready chain orders documents before outbound and candidate visibility', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda']) + const required = [ + 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', + 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', + 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', + ] + for (const module of required) assert.ok(modules.includes(module), module) + for (let index = 1; index < required.length; index += 1) { + assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) + } + assert.match(entry, /phase3-container-documents/) + assert.match(entry, /phase3-container-documents-phase4-/) +}) diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs new file mode 100644 index 000000000..44df3e249 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -0,0 +1,147 @@ +const MODULE = 'phase3-container-documents' +const OPTIONS = { scenario: 'phase3-container-four-documents', deploymentBlocker: true } +const CONTAINERS = '/api/platform/v1/containers' +const FIRST_LEG = '/api/platform/v1/first-leg' + +export const DOCUMENT_TYPES = Object.freeze([ + 'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER', +]) + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 200 && codeOf(response) === 'A0409' } +function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) } +function key(context, label) { return `${context.marker}-four-documents-${label}`.slice(0, 128) } +function validDocument(document, type, completed) { + return Boolean(document?.id) && Boolean(document?.fileId) && document?.documentType === type + && /^[0-9a-f]{64}$/i.test(String(document?.fileHash || '')) + && Number.isInteger(Number(document?.version)) + && (completed ? Boolean(document?.completedAt) : document?.completedAt == null) +} + +function documentForm(context, type, expectedVersion, operationReason, altered = false) { + const form = new FormData() + const suffix = altered ? '-altered' : '' + form.append('file', new Blob([`${context.marker}-${type}${suffix}`], { type: 'text/plain' }), + `${type.toLowerCase()}${suffix}.txt`) + form.append('expectedVersion', String(expectedVersion)) + form.append('operationReason', operationReason) + return form +} + +async function upload(context, path, type, expectedVersion, operationReason, idempotencyKey, altered = false) { + return context.requestMultipart('POST', path, + documentForm(context, type, expectedVersion, operationReason, altered), context.token, + { 'Idempotency-Key': idempotencyKey }) +} + +async function uploadAndVerify(context, { type, uploadPath, readPath, completePath = null }) { + const operationReason = `${context.marker} 四类货柜资料全链验收` + const uploadKey = key(context, `${type}-upload`) + const response = await upload(context, uploadPath, type, 0, operationReason, uploadKey) + let document = context.apiSuccess(MODULE, `上传${type}`, response, `documentType=${type}`, OPTIONS) + context.assert(MODULE, `${type}上传返回归属、文件哈希和草稿版本`, response, + validDocument(document, type, false) && traced(response), + `documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS) + if (!validDocument(document, type, false)) return null + + const replay = await upload(context, uploadPath, type, 0, operationReason, uploadKey) + const replayed = context.apiSuccess(MODULE, `${type}同键同载荷重放`, replay, '', OPTIONS) + context.assert(MODULE, `${type}上传重放不新增资料或递增版本`, replay, + id(replayed?.id) === id(document.id) && id(replayed?.fileId) === id(document.fileId) + && Number(replayed?.version) === Number(document.version), + `documentId=${id(replayed?.id) || 'missing'}, version=${replayed?.version ?? 'missing'}`, OPTIONS) + + const hashConflict = await upload(context, uploadPath, type, 0, operationReason, uploadKey, true) + context.addStep(MODULE, `${type}同键异文件返回A0409`, hashConflict, + conflict(hashConflict) && traced(hashConflict), + `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}, requestId=${hashConflict.requestId || 'missing'}, traceId=${hashConflict.traceId || 'missing'}`, OPTIONS) + + if (completePath) { + const completeKey = key(context, `${type}-complete`) + const completeBody = { expectedVersion: Number(document.version), operationReason } + const completeResponse = await context.request('POST', completePath, completeBody, context.token, + { 'Idempotency-Key': completeKey }) + document = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS) + context.assert(MODULE, `${type}完成后锁定且版本递增`, completeResponse, + validDocument(document, type, true) && Number(document.version) === Number(completeBody.expectedVersion) + 1, + `completedAt=${document?.completedAt || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS) + + const completeReplay = await context.request('POST', completePath, completeBody, context.token, + { 'Idempotency-Key': completeKey }) + const replayedComplete = context.apiSuccess(MODULE, `${type}完成同键重放`, completeReplay, '', OPTIONS) + context.assert(MODULE, `${type}完成重放保持同一终态`, completeReplay, + id(replayedComplete?.id) === id(document.id) + && Number(replayedComplete?.version) === Number(document.version) + && replayedComplete?.completedAt === document.completedAt, + `documentId=${id(replayedComplete?.id) || 'missing'}, version=${replayedComplete?.version ?? 'missing'}`, OPTIONS) + + const completeConflict = await context.request('POST', completePath, + { ...completeBody, operationReason: `${operationReason}-altered` }, context.token, + { 'Idempotency-Key': completeKey }) + context.addStep(MODULE, `${type}完成同键异载荷返回A0409`, completeConflict, + conflict(completeConflict) && traced(completeConflict), + `HTTP=${completeConflict.http}, code=${codeOf(completeConflict)}`, OPTIONS) + } + + const readResponse = await context.request('GET', readPath) + const stored = context.apiSuccess(MODULE, `回读${type}`, readResponse, '', OPTIONS) + const expectedCompleted = Boolean(completePath) + context.assert(MODULE, `${type}负向请求后持久事实零漂移`, readResponse, + validDocument(stored, type, expectedCompleted) && id(stored.id) === id(document.id) + && id(stored.fileId) === id(document.fileId) && Number(stored.version) === Number(document.version), + `documentId=${id(stored?.id) || 'missing'}, version=${stored?.version ?? 'missing'}`, OPTIONS) + return stored +} + +export async function run(context) { + const fixture = context.createdResources?.phase3Loading + const containerId = id(fixture?.containerBookingId) + const planId = id(fixture?.loadingPlanId) + const warehouseId = id(fixture?.warehouseId) + if (fixture?.marker !== context.marker || fixture?.finalizeSucceeded !== true + || fixture?.finalStatus !== 'LOADED' || !containerId || !planId || !warehouseId) { + context.addStep(MODULE, '取得本轮LOADED柜次四类资料前置', null, false, + `marker=${fixture?.marker || 'missing'}, status=${fixture?.finalStatus || 'missing'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, + OPTIONS) + return + } + + const specs = [ + { + type: 'DESTINATION_CLEARANCE', + uploadPath: `${CONTAINERS}/${containerId}/clearance/document/upload`, + readPath: `${CONTAINERS}/${containerId}/clearance`, + completePath: `${CONTAINERS}/${containerId}/clearance/complete`, + }, + { + type: 'CUSTOMS_DECLARATION', + uploadPath: `${FIRST_LEG}/loading-lists/${planId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}`, + readPath: `${FIRST_LEG}/loading-lists/${planId}/customs?warehouseId=${encodeURIComponent(warehouseId)}`, + completePath: `${FIRST_LEG}/loading-lists/${planId}/customs/complete?warehouseId=${encodeURIComponent(warehouseId)}`, + }, + ...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({ + type, + uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, + readPath: `${CONTAINERS}/${containerId}/documents/${type}`, + })), + ] + + const documents = [] + for (const spec of specs) documents.push(await uploadAndVerify(context, spec)) + const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`) + const overview = context.apiSuccess(MODULE, '回查货柜四类资料聚合', overviewResponse, '', OPTIONS) + const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType)) + context.assert(MODULE, '同一LOADED柜次聚合返回四类资料且身份未漂移', overviewResponse, + id(overview?.container?.id) === containerId && documents.every(Boolean) + && DOCUMENT_TYPES.every((type) => storedTypes.has(type)), + `containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS) + if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) { + context.createdResources.phase3ContainerDocuments = { + marker: context.marker, containerBookingId: containerId, loadingPlanId: planId, + warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES], + documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])), + } + } +} diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 18a4d7be2..99b554543 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -113,6 +113,99 @@ async function ensureReleasedOceanBooking(context, container) { return true } +async function uploadVersion(context, path, label, expectedVersion, operation, content) { + const response = await upload(context, path, { + expectedVersion, operationReason: `${context.marker}-${operation}`, + }, content, `${context.marker}-${operation}.txt`, context.token, key(context, operation)) + const document = context.apiSuccess(MODULE, label, response, '', OPTIONS) + context.assert(MODULE, `${label}返回当前唯一文件事实`, response, + Boolean(document?.id) && Boolean(document?.fileId) && Number.isInteger(Number(document?.version)) + && /^[0-9a-f]{64}$/i.test(String(document?.fileHash || '')), + `documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS) + return { response, document } +} + +async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) { + const reason = `${context.marker}-人工验收节点资料` + const simpleDocuments = [ + { type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`, + uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` }, + { type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`, + query: `warehouseId=${encodeURIComponent(warehouseId)}`, + uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` }, + ] + const completed = [] + for (const document of simpleDocuments) { + const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0, + `${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`) + if (!first.document?.fileId) return false + const readPath = `${document.base}${document.query ? `?${document.query}` : ''}` + const firstRead = await context.request('GET', readPath) + const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS) + context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead, + id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId), + `documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS) + const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE' + ? `${document.base}/document/${id(first.document.fileId)}/download` + : `${document.base}/document/${id(first.document.fileId)}/download?${document.query}` + const firstDownload = await download(context, firstDownloadPath, context.token) + context.assert(MODULE, `下载${document.type}首版`, firstDownload, + firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'), + `HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS) + const second = await uploadVersion(context, document.uploadPath, `更新${document.type}为第二版`, + Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`) + context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response, + id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1, + `documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS) + const completeResponse = await request(context, 'POST', `${document.base}/complete${document.query ? `?${document.query}` : ''}`, { + expectedVersion: Number(second.document?.version), operationReason: reason, + }, context.token, { 'Idempotency-Key': key(context, `${document.type.toLowerCase()}-complete`) }) + const complete = context.apiSuccess(MODULE, `完成${document.type}`, completeResponse, '', OPTIONS) + context.assert(MODULE, `${document.type}完成态、时间和版本可回读`, completeResponse, + id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt) + && Number(complete?.version) === Number(second.document?.version) + 1, + `completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS) + completed.push(complete) + } + + for (const type of ['DELIVERY_ORDER', 'PICKUP_ORDER']) { + const base = `/api/platform/v1/containers/${containerBookingId}/documents/${type}` + const first = await uploadVersion(context, `${base}/upload`, `首次上传${type}`, 0, + `${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`) + if (!first.document?.fileId) return false + const firstDownload = await download(context, `${base}/${id(first.document.fileId)}/download`, context.token) + context.assert(MODULE, `下载${type}首版`, firstDownload, + firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'), + `HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS) + const second = await uploadVersion(context, `${base}/upload`, `更新${type}为第二版`, + Number(first.document.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`) + const echoResponse = await context.request('GET', base) + const echo = context.apiSuccess(MODULE, `回显${type}当前版本`, echoResponse, '', OPTIONS) + context.assert(MODULE, `${type}首次与更新保持同一聚合且当前版本唯一`, echoResponse, + id(echo?.id) === id(first.document.id) && id(echo?.fileId) === id(second.document?.fileId) + && Number(echo?.version) === Number(first.document.version) + 1, + `documentId=${id(echo?.id) || 'missing'}, version=${echo?.version ?? 'missing'}`, OPTIONS) + } + + const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`) + const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS) + const documents = Array.isArray(overview?.documents) ? overview.documents : [] + const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : [] + context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse, + ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'] + .every((type) => documents.some((item) => item?.documentType === type)) + && completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true)) + && photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))), + `documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS) + for (const [index, photo] of photos.entries()) { + const response = await download(context, photo.downloadPath, context.token) + context.assert(MODULE, `下载整柜PDA照片证据${index + 1}`, response, + response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'), + `fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS) + } + return true +} + export async function run(context) { const { fixture, container } = selectCustomsFlow(context.createdResources) const tenantId = id(fixture?.tenantId) @@ -120,7 +213,8 @@ export async function run(context) { const inboundId = id(fixture?.inboundId) const containerBookingId = id(container?.containerBookingId) const loadingPlanId = id(container?.loadingPlanId) - if (!tenantId || !parentOrderId || !inboundId || !containerBookingId + const warehouseId = id(container?.warehouseId || fixture?.warehouseId) + if (!tenantId || !parentOrderId || !inboundId || !containerBookingId || !loadingPlanId || !warehouseId || !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus) || (container?.finalStatus === 'AWAITING_REVIEW' && (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) { @@ -380,6 +474,12 @@ export async function run(context) { context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference), `HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS) + if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) { + context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false, + `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS) + return + } + context.createdResources.phase3Customs = { marker: context.marker, tenantId, parentOrderId, orderId: actualOrderId, inboundId, containerBookingId, loadingPlanId, customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId, diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 36331259e..008087f06 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -32,7 +32,10 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap const aggregate = read('phase3-outbound-aggregate.mjs') assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) - assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/) + assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/) + assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) + assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) + assert.doesNotMatch(dispatch, /expectedCodes[\s\S]{0,240}'TRUCK_DEPARTED'|expectedCodes[\s\S]{0,240}'SAILING_SCHEDULE'/) assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/) assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index a489ba65c..b41aa12c0 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -43,52 +43,52 @@ export async function run(context) { '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') return } - const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(containerBookingId)}` + const selector = `containerBookingId=${encodeURIComponent(containerBookingId)}` const listResponse = await request(context, 'GET', `${BASE}?${selector}`) - const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS) - const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId - && id(item.containerBookingId) === containerBookingId) : null + const aggregates = context.apiSuccess(MODULE, '按柜次查询唯一出仓主聚合', listResponse, selector, OPTIONS) + const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.containerBookingId) === containerBookingId) : null + const inboundBinding = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === inboundId) const outboundFieldGaps = outboundAggregateFieldGaps(aggregate) context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse, - Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId) - && id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId, - `orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS) + Boolean(inboundBinding?.orderId) && Boolean(inboundBinding?.tenantId) && Boolean(aggregate?.loadingPlanId) + && id(aggregate?.containerBookingId) === containerBookingId, + `orderId=${id(inboundBinding?.orderId) || 'missing'}, tenantId=${id(inboundBinding?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}, inbounds=${aggregate?.inbounds?.length ?? 'missing'}`, OPTIONS) context.assert(MODULE, '国内出仓列表客户、订单入库、柜次计划、PDA订舱与门禁摘要完整', listResponse, outboundFieldGaps.length === 0, `missing=${outboundFieldGaps.join(',') || 'none'}`, OPTIONS) if (!aggregate) return - const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`) - const orderAggregates = context.apiSuccess(MODULE, '按 orderId 查询全部出仓柜次', orderResponse, '', OPTIONS) - context.assert(MODULE, 'orderId 查询包含本轮入库单柜次', orderResponse, - Array.isArray(orderAggregates) && orderAggregates.some((item) => id(item.inboundId) === inboundId - && id(item.containerBookingId) === containerBookingId), `records=${orderAggregates?.length ?? 'n/a'}`, OPTIONS) - - const detailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) + const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`) const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS) context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse, Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId - && id(detail?.aggregate?.containerBookingId) === containerBookingId, + && id(detail?.aggregate?.containerBookingId) === containerBookingId + && detail?.aggregate?.inbounds?.some?.((item) => id(item?.inboundId) === inboundId), `timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS) - const gateResponse = await request(context, 'GET', `${BASE}/check-gate?${selector}`) + const gateResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}/gate`) const gate = context.apiSuccess(MODULE, '执行国内出仓统一门禁', gateResponse, '', OPTIONS) - const gateItems = Array.isArray(gate?.items) ? gate.items : [] + const gateItems = Array.isArray(gate?.inbounds) + ? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : [] const expectedCodes = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED', - 'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE', - 'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE'] + 'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE'] context.assert(MODULE, '门禁逐项公开真实状态且不省略下游模块', gateResponse, - expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean')), + expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean')) + && gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)), `codes=${gateItems.map((item) => item.code).join(',')}`, OPTIONS) + context.assert(MODULE, '拖车与独立船期仅作聚合参考且不参与出仓门禁', gateResponse, + Object.hasOwn(aggregate, 'truckAppointmentStatus') && Object.hasOwn(aggregate, 'oceanBookingStatus') + && gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)), + `truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS) const expectedVersion = Number(aggregate.version) const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker } const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128) if (!gate?.passed) { - const blocked = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, + const blocked = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { expectedVersion, remark: context.marker }, { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked), `HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS) - context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false, + context.addStep(MODULE, '国内出仓六项硬门禁必须在同轮全部通过', gateResponse, false, `failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`, OPTIONS) for (const item of gateItems.filter((value) => !value.passed)) { @@ -99,7 +99,8 @@ export async function run(context) { return } - const confirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, + const confirmBody = { expectedVersion, remark: context.marker } + const confirmResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody, { 'Idempotency-Key': confirmKey }) const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS) context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse, @@ -109,7 +110,7 @@ export async function run(context) { `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) if (!confirmed?.aggregate) return - const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, + const replayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody, { 'Idempotency-Key': confirmKey }) const replay = context.apiSuccess(MODULE, 'confirm-dispatch 幂等重放', replayResponse, '', OPTIONS) context.assert(MODULE, '确认重放保持同一终态和版本', replayResponse, @@ -117,30 +118,30 @@ export async function run(context) { && Number(replay?.aggregate?.version) === Number(confirmed?.aggregate?.version), `version=${replay?.aggregate?.version ?? 'missing'}`, OPTIONS) - const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' }, + const hashConflict = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { ...confirmBody, remark: 'forged' }, { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, hashConflict.http === 200 && codeOf(hashConflict) === 'A0409', `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128) - const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { - inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, + const reverseResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, { + expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) context.addStep(MODULE, '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', reverseResponse, reverseResponse.http === 200 && codeOf(reverseResponse) === 'A0409' && String(reverseResponse.body?.message || '').includes('DEPARTED_WAREHOUSE') && String(reverseResponse.body?.message || '').includes('禁止撤销'), `HTTP=${reverseResponse.http}, code=${codeOf(reverseResponse)}, message=${reverseResponse.body?.message || 'missing'}`, OPTIONS) - const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { - inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, + const reverseReplayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, { + expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) context.addStep(MODULE, '禁止撤销边界重复请求仍精确返回A0409', reverseReplayResponse, reverseReplayResponse.http === 200 && codeOf(reverseReplayResponse) === 'A0409' && String(reverseReplayResponse.body?.message || '').includes('DEPARTED_WAREHOUSE'), `HTTP=${reverseReplayResponse.http}, code=${codeOf(reverseReplayResponse)}`, OPTIONS) - const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) + const finalDetailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`) const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse, finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' @@ -154,7 +155,7 @@ export async function run(context) { if (confirmed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, - orderId: id(confirmed.aggregate.orderId), tenantId: id(confirmed.aggregate.tenantId), + orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId), confirmedStatus: confirmed.aggregate.dispatchStatus, confirmedInboundStatus: confirmed.aggregate.inboundStatus, confirmedVersion: Number(confirmed.aggregate.version), diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 2fd9bf5c8..975b35e5e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -273,9 +273,16 @@ 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 ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 + 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 ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated 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) + `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) return ready }