This commit is contained in:
2026-08-26 14:56:33 +08:00
parent 36534fe9ce
commit 3f9ee28d22
11 changed files with 363 additions and 47 deletions
+6 -3
View File
@@ -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
+3 -1
View File
@@ -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 <development-gateway> --overseas-gateway <development-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
```
@@ -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
}
@@ -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), [])
@@ -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),
}
}
@@ -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-/)
})
@@ -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)])),
}
}
}
@@ -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,
@@ -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, /报关与船期历史并入统一时间线|当前服务端聚合未公开/)
})
@@ -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),
@@ -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
}