优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user