This commit is contained in:
2026-08-18 16:39:39 +08:00
parent 7fe5b4e31f
commit a9061eca54
43 changed files with 821 additions and 1217 deletions
+5 -4
View File
@@ -49,8 +49,9 @@ export async function run(context) {
| 入仓自动头程费用、派生详情、人工发布与 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` |
| 分柜与父子入库单数量守恒 | `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` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空) | `truck-appointments-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `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` |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
| Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` |
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
@@ -63,9 +64,9 @@ export async function run(context) {
默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为:
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。
`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱 DRAFT 完整登记 containerRequirements -> RELEASED -> 港口取得真实 containerNo -> 按 oceanBookingId + requirementId + containerNo + expectedVersion 原地关联并创建 allocation -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
@@ -1,96 +1,50 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java')
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const containerDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java')
const containerService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java')
const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java')
const oceanMapper = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java')
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const loadingFixture = read('phase3-container-loading.mjs')
const fixture = read('phase3-container-loading.mjs')
const fullEntry = read('../admin-test-env-acceptance.mjs')
const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md')
test('operational ocean booking list excludes unconfirmed data', () => {
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*CONFIRMED[\s\S]*CHANGE_PENDING[\s\S]*RELEASED[\s\S]*CANCELLED/)
assert.match(oceanMapper, /status IN \('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED'\)/)
assert.match(oceanOpenApi, /enum: \[CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED\]/)
assert.match(oceanOpenApi, /DRAFT is internal to the create transaction and legacy SUBMITTED\/FAILED values are invalid/)
assert.doesNotMatch(oceanOpenApi.match(/name: status[^\n]*/)?.[0] || '', /DRAFT|SUBMITTED/)
test('booking is an editable DRAFT aggregate and RELEASED is immutable', () => {
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*DRAFT[\s\S]*RELEASED/)
assert.match(oceanOpenApi, /Create an editable DRAFT/)
assert.match(oceanOpenApi, /Replace a DRAFT/)
assert.match(oceanOpenApi, /Delete a DRAFT; RELEASED is immutable/)
assert.doesNotMatch(oceanOpenApi, /CHANGE_PENDING|CANCELLED/)
})
test('simulation creation uses server-owned identity and formal master data', () => {
assert.match(controller, /@PostMapping[\s\S]*SimulationCreateRequest request[\s\S]*createSimulation/)
assert.match(containerDtos, /SimulationCreateRequest\(@NotBlank[\s\S]*String channelCode[\s\S]*Long warehouseId[\s\S]*String containerType[\s\S]*BigDecimal capacityCbm[\s\S]*BigDecimal maxWeightKg/)
assert.match(containerService, /requireDomesticTransitWarehouse\(request\.warehouseId\(\)\)/)
assert.match(containerService, /activeVersionId\(\)[\s\S]*priceBooks\.channels\(versionId, warehouse\.id\(\)\)/)
assert.match(containerService, /setCabinetNo\("CAB-" \+ value\.getId\(\)\)/)
assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/)
test('booking requirements never carry real container numbers or create allocations', () => {
assert.match(oceanOpenApi, /containerRequirements/)
assert.match(oceanOpenApi, /ContainerRequirementRequest/)
assert.match(oceanOpenApi, /quantity/)
assert.match(oceanOpenApi, /no containerNo, container row, or allocation is created/)
assert.doesNotMatch(oceanOpenApi.match(/ContainerRequirementRequest:[\s\S]*?ReleaseRequest:/)?.[0] || '', /containerNo/)
})
test('link consumes one active real booking container through the frozen request', () => {
assert.match(controller, /@PostMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(controller, /@DeleteMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long realContainerBookingId,[\s\S]*expectedVersion/)
assert.match(containerService, /requireFirstLinkMutable\(container\)/)
assert.match(containerService, /repository\.findForUpdate\(request\.realContainerBookingId\(\)\)/)
assert.match(containerService, /"OCEAN_BOOKING"\.equals\(source\.getSourceType\(\)\)/)
assert.match(containerService, /"ACTIVE"\.equals\(source\.getRecordStatus\(\)\)/)
assert.match(containerService, /"CONFIRMED"\.equals\(booking\.getStatus\(\)\)[\s\S]*"RELEASED"\.equals\(booking\.getStatus\(\)\)/)
assert.match(containerService, /hasPickTask[\s\S]*hasEvidence[\s\S]*hasDocuments/)
assert.match(firstlegOpenApi, /realContainerBookingId/)
assert.match(firstlegOpenApi, /原真实柜置 MERGED 且不再出现在正常货柜列表/)
test('port link mutates the simulation through the frozen four-field request', () => {
assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long oceanBookingId,[\s\S]*Long requirementId,[\s\S]*String containerNo,[\s\S]*Integer expectedVersion/)
assert.match(containerService, /request\.oceanBookingId\(\)/)
assert.match(containerService, /request\.requirementId\(\)/)
assert.match(containerService, /request\.containerNo\(\)/)
assert.match(containerService, /["']RELEASED["']\.equals\(booking\.getStatus\(\)\)/)
assert.doesNotMatch(fixture, /realContainerBookingId/)
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
})
test('real booking merge locks scope and changes all identities atomically', () => {
for (const field of ['warehouseId', 'channelCode', 'containerType', 'capacityCbm', 'maxWeightKg']) {
assert.match(containerService, new RegExp(`container\\.get${field[0].toUpperCase()}${field.slice(1)}\\(\\)`), field)
}
assert.match(containerService, /hasMismatchedCustomerItems/)
assert.match(containerService, /consumeRealSource\(source\.getId\(\), id/)
assert.match(containerService, /mergeRealSource\(id, source\.getId\(\), source\.getContainerNo\(\)/)
assert.match(containerService, /reassignAllocation\(source\.getId\(\), id\)/)
assert.doesNotMatch(containerService.match(/linkOceanBooking[\s\S]*?return required\(id\);/)?.[0] || '', /container\.setId\(|container\.setCabinetNo\(/)
test('HTTP fixture keeps simulation and plan identities and creates allocation at link', () => {
assert.match(fixture, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(fixture, /订舱 allocation 原子创建并指向稳定模拟柜身份/)
assert.match(fixture, /已消费订舱需求拒绝再次关联/)
assert.match(fixture, /RELEASED 订舱进入正式列表/)
})
test('booking creation atomically registers one real operational container per input row', () => {
assert.match(oceanOpenApi, /containers: \{type: array, minItems: 1, maxItems: 20/)
assert.match(oceanOpenApi, /required: \[containerNo, containerType, containerKind, capacityCbm, maxWeightKg\]/)
assert.match(oceanService, /request\.data\(\)\.containers\(\)\.forEach/)
assert.match(oceanService, /同一订舱内集装箱号不能重复/)
assert.match(oceanService, /createRealContainers\(value, requirements, request\.data\(\)\.containers\(\)/)
assert.match(oceanService, /setSourceType\("OCEAN_BOOKING"\)/)
assert.match(oceanService, /setRecordStatus\("ACTIVE"\)/)
assert.match(oceanService, /setQuantity\(1\)/)
})
test('HTTP fixture follows simulation arrange link PDA sequence without auto-container fallback', () => {
const ordered = [
'新增内部模拟柜',
'人工分配本轮候选货物',
'首次关联真实已确认订舱柜',
'查询本轮柜次 PDA 拣货任务',
].map((label) => loadingFixture.indexOf(label))
assert.ok(ordered.every((value) => value >= 0), `missing sequence label: ${ordered.join(',')}`)
assert.ok(ordered.every((value, index) => index === 0 || value > ordered[index - 1]), `invalid sequence: ${ordered.join(',')}`)
assert.match(loadingFixture, /ocean-booking-link/)
assert.match(loadingFixture, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(loadingFixture, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/)
assert.match(loadingFixture, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(loadingFixture, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/)
})
test('default full acceptance preserves the linked simulation chain before downstream completion', () => {
test('default full acceptance keeps the linked simulation before PDA and finalize', () => {
const loading = fullEntry.indexOf("'phase3-container-loading'")
const pda = fullEntry.indexOf("'phase3-pda-convergence'")
const finalize = fullEntry.indexOf("'phase3-loading-finalize'")
const outbound = fullEntry.indexOf("'phase3-outbound-dispatch'")
assert.ok(loading >= 0 && pda > loading && finalize > pda && outbound > finalize)
assert.match(matrix, /内部模拟柜关联真实订舱增量/)
assert.doesNotMatch(matrix, /CONFIRMED` 自动建柜[\s\S]*既有冻结/)
assert.ok(loading >= 0 && pda > loading && finalize > pda)
})
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const loading = read('phase3-loading.mjs')
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const outbox = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java')
const event = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java')
const admin = read('../../../apps/admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue')
test('fixture locks customer and latest internal note sources without cargoRemark leakage', () => {
assert.match(loading, /assignedItem\?\.customerRemark === orderRemarks\.customerRemark/)
assert.match(loading, /assignedItem\?\.internalRemark === orderRemarks\.internalRemark/)
assert.match(loading, /assignedItem\?\.internalRemark !== orderRemarks\.cargoRemark/)
assert.match(service, /item\.setCustomerRemark\(cargo\.customerRemark\(\)\)/)
assert.match(service, /item\.setInternalRemark\(cargo\.latestInternalNote\(\)\)/)
})
test('warehouse remark PATCH covers save clear stale version and source preservation', () => {
assert.match(openapi, /items\/\{itemId\}\/warehouse-remark/)
assert.match(openapi, /expectedPlanVersion/)
assert.match(openapi, /expectedItemVersion/)
assert.match(openapi, /null\/空字符串均表示清空/)
assert.match(openapi, /COMPLETED、计划\/明细版本冲突返回 A0409 且事务零写入/)
assert.match(openapi, /数量调整或动态增量不会覆盖已有 warehouseRemark/)
assert.match(loading, /EDITING 装柜明细保存仓库备注/)
assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/)
assert.match(loading, /仓库备注精确重放不重复递增版本或历史事实/)
assert.match(loading, /仓库备注旧版本 A0409 且零写入/)
assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/)
assert.match(loading, /EDITING 装柜明细空白仓库备注归一化为 null/)
assert.match(loading, /恢复真实仓库备注供装柜完成事件下发/)
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
})
test('completed event V2 carries all three remarks with stable event payload', () => {
assert.match(outbox, /FirstLegLoadingPlanCompletedV2/)
for (const field of ['customerRemark', 'internalRemark', 'warehouseRemark']) assert.match(event, new RegExp(field))
assert.match(outbox, /row\.getCustomerRemark\(\)/)
assert.match(outbox, /row\.getInternalRemark\(\)/)
assert.match(outbox, /row\.getWarehouseRemark\(\)/)
assert.match(outbox, /HANDOVER_VERSION = 2L/)
assert.match(outbox, /EventPayloadCanonicalizer\.canonicalize\(payloadNode\)/)
assert.match(outbox, /FIRSTLEG_LOADING_COMPLETED:[^\n]*HANDOVER_VERSION/)
assert.match(outbox, /EventTypes\.FIRST_LEG_LOADING_PLAN_COMPLETED, 2/)
})
test('Admin exposes only warehouse remark editing and keeps completed rows read-only', () => {
assert.match(admin, /备注(客户)/)
assert.match(admin, /备注(内部)/)
assert.match(admin, /备注(仓库)/)
assert.match(admin, /编辑备注/)
assert.match(admin, /COMPLETED/)
assert.doesNotMatch(admin, /v-model="[^\"]*customerRemark|v-model="[^\"]*internalRemark/)
})
@@ -113,7 +113,7 @@ async function createConfirmedContainer(context, source, label) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
@@ -127,28 +127,24 @@ async function createConfirmedContainer(context, source, label) {
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
const bookingId = id(created?.oceanBooking?.id)
const confirmed = created
context.assert(MODULE, `${label} 一次登记直接返回 CONFIRMED`, createdResponse,
confirmed?.oceanBooking?.status === 'CONFIRMED',
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker,
}, `${label}-booking-release`)
const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS)
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0),
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
const requirement = confirmed?.containers?.find?.((item) => item.containerNo === containerNo)
if (!requirement?.containerBookingId) throw new Error(`${label} 确认订舱未返回真实柜身份`)
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS)
if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
}
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmed?.oceanBooking?.version),
billOfLadingNo: confirmed?.oceanBooking?.billOfLadingNo,
vesselName: confirmed?.oceanBooking?.vesselName, voyageNo: confirmed?.oceanBooking?.voyageNo,
estimatedSailingAt: confirmed?.oceanBooking?.estimatedSailingAt,
estimatedArrivalAt: confirmed?.oceanBooking?.estimatedArrivalAt,
reason: context.marker,
}, `${label}-booking-release`)
context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS)
context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS)
return containerId
}
@@ -1,35 +1,21 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8')
test('retains only operational ocean booking states from direct CONFIRMED registration', () => {
assert.match(source, /const OCEAN_BASE = ['"]\/api\/platform\/v1\/ocean-bookings['"]/)
assert.doesNotMatch(source, /\/api\/platform\/v1\/first-leg\/ocean-bookings/)
for (const status of ['CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED',
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
assert.match(source, /change-request/)
assert.match(source, /const OCEAN_LIST_STATUSES = \['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'\]/)
assert.match(source, /const OCEAN_STATUSES = \[\.\.\.OCEAN_LIST_STATUSES\]/)
assert.doesNotMatch(source, /OCEAN_HIDDEN_STATUSES|hiddenFixtures|verifyHiddenOcean/)
assert.match(source, /一次创建直接返回 CONFIRMED/)
assert.doesNotMatch(source, /ocean-bookings\/\$\{[^}]+\}\/submit|ocean-bookings\/\$\{[^}]+\}\/confirm/)
const draftPayload = source.slice(source.indexOf('function oceanPayload'), source.indexOf('async function createOcean'))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(draftPayload, new RegExp(`${field}:`))
test('manual matrix retains only DRAFT and RELEASED ocean booking states', () => {
assert.match(source, /const OCEAN_LIST_STATUSES = \['DRAFT', 'RELEASED'\]/)
assert.match(source, /containerRequirements:/)
assert.doesNotMatch(source, /change-request|\/cancel/)
assert.doesNotMatch(source, /realContainerBookingId|containerNo:\s*`MAN-/)
assert.match(source, /status === 'DRAFT'/)
assert.match(source, /status === 'RELEASED'/)
})
test('truck matrix remains independent while retaining its own lifecycle', () => {
for (const status of ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS',
'ARRIVED_WAREHOUSE', 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']) {
assert.match(source, new RegExp(`['\"]${status}['\"]`))
}
const lifecycle = source.slice(source.indexOf('async function buildOcean'), source.indexOf('function truckPayload'))
assert.doesNotMatch(lifecycle, /'submit'|'confirm'|'fail'/)
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
assert.match(source, /page=1&size=100&status=/)
assert.match(source, /customerId: oceanBooking\.customerId/)
assert.match(source, /warehouseId: oceanBooking\.warehouseId/)
assert.doesNotMatch(source, /customerId: ['"]10001['"]/)
assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/)
assert.match(source, /carrierId: null, carrierName: null/)
assert.doesNotMatch(source, /人工验收车队-/)
assert.match(source, /billOfLadingNo: value\.oceanBooking\.billOfLadingNo/)
assert.doesNotMatch(source, /DELETE/)
})
@@ -23,8 +23,7 @@ test('Admin ocean booking selects a warehouse-scoped logistics channel instead o
assert.match(page, /shippingCompanyId/)
assert.match(page, /channelCode/)
assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/)
assert.match(pageTest, /expect\(source\)\.toContain\(['"]:model-value="form\.data\.channelCode" filterable['"]\)/)
assert.match(pageTest, /expect\(source\)\.not\.toContain\(['"]<el-input v-model="form\.data\.channelCode"['"]\)/)
assert.match(pageTest, /listWarehouseLogisticsChannels|channelCode/)
})
test('backend OpenAPI freezes a platform channel query scoped by originWarehouseId', () => {
@@ -33,15 +32,16 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse
.map((name) => source(new URL(name, openapiUrl)))
const platformChannelContract = documents.find((value) =>
/\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value)
&& /server validates it against the current ACTIVE price book/.test(value))
&& /current ACTIVE price book/.test(value))
assert.ok(platformChannelContract,
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
})
test('explicit real booking association locks the selected channel onto the simulation', () => {
test('released requirement association locks the selected channel onto the simulation', () => {
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
assert.match(fixture, /ocean-booking-link/)
assert.match(fixture, /一次登记直接返回 CONFIRMED 且尚未占用模拟柜/)
assert.match(fixture, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
assert.match(fixture, /selectPlatformChannelIsolationPair/)
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
@@ -4,161 +4,73 @@ import test from 'node:test'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => {
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
const master = { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' }
test('fixture resolves current-marker customer channel and warehouse without inventing data', async () => {
const steps = []
const context = {
marker: 'ADMIN-RUN-1',
createdResources: { orchestrationFixtures: { phase3Container: {
marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31',
} } },
request: async () => ({ body: { code: '200', data: { summary: {
tenantId: 21, customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: 41, warehouseName: '深圳仓',
} } } }),
addStep: (...args) => steps.push(args),
}
const master = await resolveOceanBookingMaster(context, 'test')
assert.deepEqual(master, { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US',
channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓' })
const context = { marker: 'ADMIN-RUN-1', createdResources: { orchestrationFixtures: { phase3Container: { marker: 'ADMIN-RUN-1', tenantId: '21', inboundId: '31' } } },
request: async () => ({ body: { code: '200', data: { summary: { tenantId: 21, customerName: master.customerName, channelCode: master.channelCode, channelName: master.channelName, warehouseId: 41, warehouseName: master.warehouseName } } } }),
addStep: (...args) => steps.push(args) }
assert.deepEqual(await resolveOceanBookingMaster(context, 'test'), master)
assert.equal(steps.length, 0)
const blocked = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) }
assert.equal(await resolveOceanBookingMaster(blocked, 'test'), null)
assert.match(steps.at(-1)[4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('ocean booking fixture blocks creation instead of inventing missing master data', async () => {
const steps = []
const context = { marker: 'ADMIN-RUN-2', createdResources: {}, addStep: (...args) => steps.push(args) }
assert.equal(await resolveOceanBookingMaster(context, 'test'), null)
assert.equal(steps.length, 1)
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('shared booking data omits server-owned names and registers unique real containers', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId']) {
assert.ok(draft[key], `${key} is required`)
}
test('shared DRAFT uses requirements and never registers a real container number', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master)
assert.equal(draft.channelName, undefined)
assert.equal(draft.warehouseName, undefined)
assert.equal(draft.shippingCompanyId, null)
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentId, null)
assert.equal(draft.bookingAgentName, null)
assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }])
assert.equal(Object.hasOwn(draft, 'containers'), false)
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.deepEqual(draft.containers, [{ containerNo: 'TST-3-01', containerType: '40HC',
containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }])
assert.equal(Object.hasOwn(draft, 'containerRequirements'), false)
assert.equal(draft.billOfLadingNo, 'BL-3')
assert.equal(draft.vesselName, '验收船-3')
assert.equal(draft.voyageNo, 'V-3')
assert.equal(draft.estimatedSailingAt, '2026-09-01T08:00:00')
assert.equal(draft.estimatedArrivalAt, '2026-09-20T08:00:00')
const fixtureSource = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const builderSource = fixtureSource.slice(fixtureSource.indexOf('export function oceanBookingDraft'), fixtureSource.indexOf('export function oceanBookingDraftWithoutCustomer'))
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(builderSource, new RegExp(`${key}\\s*:`), `${key} must be emitted by the confirmed booking builder`)
}
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
})
test('shared booking data never sends supplier names without valid supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
test('shared DRAFT permits an omitted customer without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', master)
assert.equal(Object.hasOwn(draft, 'customerId'), false)
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.containerRequirements[0].quantity, 1)
})
test('explicit requirement rows preserve quantity and fixed catalog facts', () => {
const requirements = [
{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements })
assert.deepEqual(draft.containerRequirements, requirements)
assert.equal(JSON.stringify(draft).includes('containerNo'), false)
})
test('supplier names are emitted only with formal supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', master,
{ shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentName, null)
})
test('shared booking draft can omit customer identity without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', {
customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
assert.equal(Object.hasOwn(draft, 'customerId'), false)
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.channelCode, 'SEA-US')
assert.equal(draft.warehouseId, '41')
assert.equal(draft.containers[0].containerType, '40HC')
assert.equal(draft.containers[0].capacityCbm, 76)
assert.equal(draft.containers[0].maxWeightKg, 28000)
})
test('shared booking builder accepts explicit isolated multi-container rows without quantity', () => {
const containers = [
{ containerNo: 'TST-MULTI-01', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: 'TST-MULTI-02', containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { containers })
assert.deepEqual(draft.containers, containers)
assert.equal(draft.containers.some((item) => Object.hasOwn(item, 'quantity')), false)
})
test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => {
test('platform channel producer uses the selected origin warehouse and publishes current-marker resources', async () => {
const calls = []
const context = {
marker: 'ADMIN-RUN-CHANNELS', createdResources: {},
request: async (method, path) => {
calls.push({ method, path })
return { body: { code: '200', data: [
{ channelCode: 'SEA-A', channelName: '海运 A' },
{ channelCode: 'SEA-B', channelName: '海运 B' },
] } }
},
apiSuccess: (_module, _name, response) => response.body.data,
assert: (_module, _name, _response, condition) => assert.equal(condition, true),
}
const context = { marker: 'ADMIN-RUN-CHANNELS', createdResources: {}, request: async (method, path) => {
calls.push({ method, path }); return { body: { code: '200', data: [{ channelCode: 'SEA-A', channelName: '海运 A' }] } }
}, apiSuccess: (_module, _name, response) => response.body.data,
assert: (_module, _name, _response, condition) => assert.equal(condition, true) }
const catalog = await publishPlatformLogisticsChannels(context, '41', 'test')
assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }])
assert.equal(catalog.marker, context.marker)
assert.equal(catalog.originWarehouseId, '41')
assert.deepEqual(catalog.channels.map((value) => value.channelCode), ['SEA-A', 'SEA-B'])
assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog)
})
test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => {
const regions = [
['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse', false],
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean', false],
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate', false],
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment', true],
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'", false],
]
for (const [name, start, end, usesSharedBuilder] of regions) {
const value = readFileSync(new URL(name, import.meta.url), 'utf8')
const region = value.slice(value.indexOf(start), value.indexOf(end))
assert.doesNotMatch(region, /channelName\s*:/, name)
if (usesSharedBuilder) {
assert.match(region, /oceanBookingDraft\(/, `${name} must use the shared formal booking builder`)
} else {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(region, new RegExp(`${field}\\s*:`), `${name} must register ${field}`)
}
}
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
assert.doesNotMatch(container, /data:\s*\{[^}]*channelName\s*:/s)
})
test('ocean booking producers register CONFIRMED data directly without draft submit or confirm transitions', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-loading.mjs', 'phase3-container-loading.mjs',
'phase3-order-splitting.mjs', 'manual-state-loading-pda.mjs', 'manual-state-ocean-trucking.mjs']) {
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
assert.doesNotMatch(source, /ocean-bookings\/[^`'"\\s]+\/(?:submit|confirm)|booking-(?:submit|confirm)/, name)
test('all production booking fixtures use DRAFT release and the shared requirements contract', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-loading.mjs', 'phase3-container-loading.mjs', 'phase3-order-splitting.mjs', 'manual-state-loading-pda.mjs', 'manual-state-ocean-trucking.mjs']) {
const source = read(name)
assert.doesNotMatch(source, /realContainerBookingId|ocean-bookings\/[^`'"\s]+\/(?:submit|confirm)/, name)
assert.match(source, /containerRequirements/, name)
}
})
test('all independent ocean booking producers use the shared master-data contract', () => {
for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) {
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
assert.match(source, /resolveOceanBookingMaster\(context, MODULE\)/, name)
assert.match(source, /oceanBookingDraft\(context, suffix, master/, name)
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName',
"containerType: '40HC'", 'capacityCbm: 76', 'maxWeightKg: 28000']) assert.match(container, new RegExp(snippet), snippet)
})
@@ -2,114 +2,59 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const adminPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8')
const adminTest = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url), 'utf8')
const adminTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8')
const prototype = readFileSync(new URL('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html', import.meta.url), 'utf8')
const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8')
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8')
const schema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8')
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8')
const bookingModule = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8')
const containerLoading = readFileSync(new URL('./phase3-container-loading.mjs', import.meta.url), 'utf8')
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
const adminPage = read('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue')
const adminTypes = read('../../../apps/admin-web/src/modules/ocean-bookings/types.ts')
const prototype = read('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html')
const dto = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java')
const openapi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
const fixture = read('ocean-booking-fixture.mjs')
const booking = read('ocean-bookings.mjs')
const loading = read('phase3-container-loading.mjs')
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const releaseFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
test('prototype evidence keeps service term freight term and optional customer fields', () => {
test('prototype evidence keeps terms and optional customer while current DRAFT stays editable', () => {
assert.match(prototype, /主要运输条款 Service Term \*<\/label><select><option>CY-CY<\/option>/)
assert.match(prototype, /运费条款 Freight Term \*<\/label><select><option>NINGBO<\/option>/)
assert.match(prototype, /<label>客户\/公司名称<\/label><input id="booking-customer"/)
})
test('Admin keeps source-backed free text terms without invented option dictionaries', () => {
assert.match(adminPage, /v-model="form\.data\.serviceTerm"[^>]*maxlength="32"/)
assert.match(adminPage, /v-model="form\.data\.freightTerm"[^>]*maxlength="32"/)
assert.doesNotMatch(adminPage, /serviceTermOptions|freightTermOptions/)
for (const invented of ['CY-DOOR', 'DOOR-CY', 'DOOR-DOOR', 'PREPAID', 'COLLECT']) {
assert.doesNotMatch(adminPage, new RegExp(invented), `${invented} must not be an invented Admin option`)
}
assert.match(adminTest, /not\.toContain\('serviceTermOptions'\)/)
assert.match(adminTest, /not\.toContain\('freightTermOptions'\)/)
})
test('service and freight terms are required across Java OpenAPI and SQL', () => {
assert.match(dto, /@NotBlank @Size\(max = 32\) String serviceTerm/)
assert.match(dto, /@NotBlank @Size\(max = 32\) String freightTerm/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.match(required, /serviceTerm/)
assert.match(required, /freightTerm/)
assert.match(openapi, /serviceTerm: \{type: string, maxLength: 32, description: Required free-text Service Term snapshot/)
assert.match(openapi, /freightTerm: \{type: string, maxLength: 32, description: Required free-text Freight Term snapshot/)
assert.doesNotMatch(openapi, /(?:serviceTerm|freightTerm): \{[^\n]*nullable: true/)
for (const sql of [schema, unifiedSql]) {
assert.match(sql, /service_term VARCHAR\(32\) NOT NULL/)
assert.match(sql, /freight_term VARCHAR\(32\) NOT NULL/)
}
})
test('customer is optional across Admin DraftData DTO and OpenAPI', () => {
assert.match(adminPage, /label="客户(可选)"[\s\S]*?filterable clearable/)
assert.doesNotMatch(adminPage, /customerId:\s*\[\{\s*required:\s*true/)
assert.match(adminPage, /const editable = computed\(\(\) => isNew\.value \|\| detail\.value\?\.status === 'DRAFT'\)/)
assert.match(adminTypes, /customerId:\s*string \| null;\s*customerName:\s*string \| null/)
assert.match(dto, /record DraftData\(@Positive Long customerId,\s*@Size\(max = 128\) String customerName/)
assert.doesNotMatch(dto, /record DraftData\(@NotNull @Positive Long customerId/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.ok(required, 'BLOCKED_CONTRACT: DraftData.required is missing from OpenAPI')
assert.doesNotMatch(required, /customerId|customerName/)
assert.match(dto, /record DraftData\(@Positive Long customerId/)
assert.match(openapi, /customerId: \{[^\n]*nullable: true/)
assert.match(openapi, /customerName: \{[^\n]*nullable: true/)
})
test('offline-confirmed registration requires transport values while release keeps the frozen five-field snapshot', () => {
const draftType = adminTypes.slice(adminTypes.indexOf('export interface OceanBookingDraftData'), adminTypes.indexOf('export interface OceanBookingResource'))
for (const field of releaseFields) {
assert.match(adminPage, new RegExp(`v-model="form\\.data\\.${field}"`),
`${field} must be entered when registering an offline-confirmed booking`)
assert.match(draftType, new RegExp(`${field}\\??:`), `${field} must belong to OceanBookingDraftData`)
test('DRAFT can be partial but release validates and freezes the five transport facts', () => {
assert.match(openapi, /description: Fields may be incomplete in DRAFT\. RELEASE validates the complete persisted aggregate/)
assert.match(openapi, /ReleaseRequest: \{type: object, required: \[expectedVersion\]/)
for (const field of transportFields) {
assert.match(adminPage, new RegExp(`form\\.data\\.${field}`), field)
assert.match(fixture, new RegExp(`${field}:`), field)
}
assert.match(adminTest, /线下订舱资料已登记并确认/)
assert.match(adminTest, /登记后直接确认/)
const saveRegion = adminPage.slice(adminPage.indexOf('async function save'), adminPage.indexOf('function requireReason'))
assert.doesNotMatch(saveRegion, /data:\s*form\.data/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
for (const field of releaseFields) assert.match(required, new RegExp(field))
assert.match(dto, /@NotBlank @Size\(max = 128\) String billOfLadingNo/)
assert.match(dto, /@NotBlank @Size\(max = 128\) String vesselName/)
assert.match(dto, /@NotBlank @Size\(max = 64\) String voyageNo/)
assert.match(dto, /@NotNull LocalDateTime estimatedSailingAt/)
assert.match(dto, /@NotNull LocalDateTime estimatedArrivalAt/)
const releaseCall = adminPage.slice(adminPage.indexOf('releaseOceanBooking(id.value'), adminPage.indexOf('releaseDialogOpen.value = false'))
for (const field of releaseFields) assert.match(releaseCall, new RegExp(field))
assert.match(booking, /const frozen = transportSnapshot\(completedDraft\?\.oceanBooking \|\| data\)/)
assert.match(booking, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(booking, /Object\.entries\(frozen\)\.every/)
assert.match(booking, /RELEASED 编辑拒绝且零写入/)
})
test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer isolation', () => {
const unassignedFlow = bookingModule.slice(bookingModule.indexOf('async function createConfirmedUnassignedBooking'), bookingModule.indexOf('async function releaseLoadingBooking'))
assert.match(fixture, /oceanBookingDraftWithoutCustomer/)
assert.match(fixture, /delete draft\.customerId[\s\S]*delete draft\.customerName/)
assert.match(fixture, /serviceTerm: 'CY-CY', freightTerm: 'NINGBO'/)
assert.doesNotMatch(fixture, /serviceTerm: 'CY\/CY'|freightTerm: 'PREPAID'/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /未指定客户订舱保持空客户且原子生成一条真实柜/)
assert.match(bookingModule, /按真实订舱号查询未指定客户订舱/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(containerLoading, /source\?\.marker !== context\.marker/)
assert.match(containerLoading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(containerLoading, /id\(item\.accountId\) === tenantId/)
test('booking requirements replace booking-time real container rows', () => {
assert.match(openapi, /containerRequirements:/)
assert.match(openapi, /ContainerRequirementRequest: \{type: object, required: \[containerType, containerKind, quantity, capacityCbm, maxWeightKg\]/)
assert.match(fixture, /containerRequirements/)
assert.doesNotMatch(fixture, /containerNo|realContainerBookingId/)
assert.match(booking, /订舱创建返回 DRAFT 且只持久化柜型需求/)
assert.match(booking, /没有预生成货柜/)
})
test('independent ocean booking release reuses the server-frozen transport snapshot', () => {
assert.match(bookingModule, /estimatedSailingAt: detail\.oceanBooking\.estimatedSailingAt/)
assert.match(bookingModule, /estimatedArrivalAt: detail\.oceanBooking\.estimatedArrivalAt/)
assert.match(bookingModule, /const releaseSnapshotResponse = await request\(context, 'GET', path\)/)
assert.match(bookingModule, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(bookingModule, /const frozenTransportMatches = frozenTransportValid/)
assert.match(bookingModule, /if \(!frozenTransportMatches\) return/)
assert.match(bookingModule, /expectedVersion: version, \.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(bookingModule, /expectedVersion: version, billOfLadingNo: `BL-\$\{suffix\}`/)
assert.doesNotMatch(bookingModule, /estimatedSailingAt: '2026-09-01T08:00:00'/)
test('port link supplies the real number and preserves current-marker customer isolation', () => {
assert.match(loading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(loading, /source\?\.marker !== context\.marker/)
assert.match(loading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(loading, /id\(item\.accountId\) === tenantId/)
assert.doesNotMatch(loading, /realContainerBookingId|MERGED/)
})
test('normal booking producers have only DRAFT and RELEASED lifecycle operations', () => {
assert.doesNotMatch(booking, /CHANGE_PENDING|CANCELLED|\/submit|\/confirm/)
assert.match(booking, /status === 'DRAFT'/)
assert.match(booking, /status === 'RELEASED'/)
})
@@ -489,6 +489,9 @@ export async function run(context) {
context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode
context.createdResources.orders.customerRemark = payload.customerRemark
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
context.createdResources.orders.processStatus = inbound.summary.processStatus
context.createdResources.ocrInbound = { tenantId, inboundId, marker: context.marker }
context.createdResources.testInbound = { tenantId, inboundId, marker: context.marker }
@@ -464,26 +464,6 @@ export async function run(context) {
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
const confirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmedTaskAbsent = confirmedTaskResponse.http === 200 && codeOf(confirmedTaskResponse) === 'A0404'
context.addStep(MODULE, '订舱已关联但未 RELEASED 不生成 PDA 任务', confirmedTaskResponse,
confirmedTaskAbsent, responseEvidence(confirmedTaskResponse), OPTIONS)
if (!confirmedTaskAbsent) return
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
...frozenTransport, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containers?.some?.((item) => id(item.containerBookingId) === containerId
&& item.containerNo === realContainerNo)
&& transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
@@ -23,16 +23,15 @@ const containerLoadingAcceptance = read('phase3-container-loading.mjs')
const truckAcceptance = read('truck-appointments.mjs')
const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md')
test('registration freezes all five transport fields and release reuses the confirmed snapshot', () => {
assert.match(oceanOpenApi, /required: \[expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt\]/)
test('DRAFT stores all five transport fields and release validates the persisted snapshot', () => {
assert.match(oceanOpenApi, /ReleaseRequest: \{type: object, required: \[expectedVersion\]/)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(releaseTypes, new RegExp(`${field}: string`), field)
assert.match(releaseView, new RegExp(`form\\.data\\.${field}`), field)
assert.match(releaseView, new RegExp(`detail\\.value\\.${field}`), field)
assert.match(oceanFixture, new RegExp(`${field}:`), field)
}
assert.match(releaseView, /当前订舱缺少已确认运输资料,无法放舱/)
assert.match(releaseView, /放舱不会重新采集或修改这些信息/)
assert.match(oceanAcceptance, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(oceanAcceptance, /RELEASED 编辑拒绝且零写入/)
})
test('overview regression requires workflowStatus and distinguishes 100 percent awaiting review', () => {
@@ -72,8 +71,9 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', (
assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/)
assert.match(truckAttachmentDialog, /上传并关联/)
assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/)
assert.match(truckAcceptance, /删除后回查拖车预约附件列表/)
assert.match(truckAcceptance, /!remainingAttachments\.some/)
assert.match(truckAcceptance, /attachmentType=BILL_OF_LADING/)
assert.match(truckAcceptance, /派车前保留有效 BILL_OF_LADING 提单附件/)
assert.match(truckAcceptance, /缺提单号与文件明确拒绝/)
assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/)
})
@@ -85,14 +85,13 @@ test('existing container number replacement keeps container plan and PDA identit
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
})
test('PDA task opens only after no-number push, real-container merge, release and plan-level confirmation', () => {
test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoadingAcceptance, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(containerLoadingAcceptance, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
@@ -102,7 +101,8 @@ test('PDA task opens only after no-number push, real-container merge, release an
assert.match(containerLoadingAcceptance, /计划级确认同 key 异参返回冲突/)
assert.match(containerLoadingAcceptance, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(containerLoadingAcceptance, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(containerLoadingAcceptance, /confirmedBooking\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.doesNotMatch(containerLoadingAcceptance, /realContainerBookingId|MERGED/)
})
test('customer public display uses tenantName without a legalName fallback', () => {
@@ -177,6 +177,74 @@ export async function run(context) {
&& Number(assignedLoadingPlan?.version) === 1,
`loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId)
const orderRemarks = context.createdResources?.orders || {}
context.assert(MODULE, '装柜明细客户备注与最新内部备注来源精确且不冒用货物备注', assignmentResponse,
assignedItem?.customerRemark === orderRemarks.customerRemark
&& assignedItem?.internalRemark === orderRemarks.internalRemark
&& assignedItem?.internalRemark !== orderRemarks.cargoRemark,
`customerRemark=${assignedItem?.customerRemark || 'missing'}, internalRemark=${assignedItem?.internalRemark || 'missing'}, cargoRemark=${orderRemarks.cargoRemark || 'missing'}`, OPTIONS)
if (!assignedItem?.id || !Number.isInteger(Number(assignedItem.version))) return
const warehouseRemark = `${context.marker}-WAREHOUSE-REMARK`
const warehouseRemarkPath = `${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`
const warehouseRemarkBody = Object.freeze({ warehouseRemark,
expectedPlanVersion: Number(assignedLoadingPlan.version), expectedItemVersion: Number(assignedItem.version),
operationId: `${context.marker}-warehouse-remark`.slice(0, 128) })
const remarkResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const remarkedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细保存仓库备注', remarkResponse, '', OPTIONS)
const remarkedItem = remarkedPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注保存后计划与明细版本递增且三备注并存', remarkResponse,
remarkedItem?.warehouseRemark === warehouseRemark
&& Number(remarkedPlan?.version) > Number(assignedLoadingPlan.version)
&& Number(remarkedItem?.version) > Number(assignedItem.version)
&& remarkedItem?.customerRemark === orderRemarks.customerRemark
&& remarkedItem?.internalRemark === orderRemarks.internalRemark,
`planVersion=${remarkedPlan?.version ?? 'missing'}, itemVersion=${remarkedItem?.version ?? 'missing'}`, OPTIONS)
if (!remarkedItem?.id) return
const remarkReplayResponse = await request(context, 'PATCH', warehouseRemarkPath, warehouseRemarkBody)
const replayedRemarkPlan = context.apiSuccess(MODULE, '仓库备注相同 operationId 与原请求精确重放', remarkReplayResponse, '', OPTIONS)
const replayedRemarkItem = replayedRemarkPlan?.items?.find?.((item) => id(item.id) === id(assignedItem.id))
context.assert(MODULE, '仓库备注精确重放不重复递增版本或历史事实', remarkReplayResponse,
Number(replayedRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(replayedRemarkItem?.version) === Number(remarkedItem?.version)
&& replayedRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${replayedRemarkPlan?.version ?? 'missing'}, itemVersion=${replayedRemarkItem?.version ?? 'missing'}`, OPTIONS)
const staleRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(assignedItem.id)}/warehouse-remark`, {
warehouseRemark: `${warehouseRemark}-STALE`, expectedPlanVersion: Number(assignedLoadingPlan.version),
expectedItemVersion: Number(assignedItem.version), operationId: `${context.marker}-warehouse-remark-stale`.slice(0, 128),
})
context.addStep(MODULE, '仓库备注旧版本 A0409 且零写入', staleRemarkResponse,
staleRemarkResponse.http === 200 && codeOf(staleRemarkResponse) === 'A0409',
`HTTP=${staleRemarkResponse.http}, code=${codeOf(staleRemarkResponse)}`, OPTIONS)
const afterStaleRemarkResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterStaleRemarkPlan = context.apiSuccess(MODULE, '仓库备注旧版本冲突后回查计划', afterStaleRemarkResponse, '', OPTIONS)
const afterStaleRemarkItem = afterStaleRemarkPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '仓库备注旧版本冲突后版本与备注事实零漂移', afterStaleRemarkResponse,
Number(afterStaleRemarkPlan?.version) === Number(remarkedPlan?.version)
&& Number(afterStaleRemarkItem?.version) === Number(remarkedItem?.version)
&& afterStaleRemarkItem?.warehouseRemark === warehouseRemark,
`planVersion=${afterStaleRemarkPlan?.version ?? 'missing'}, itemVersion=${afterStaleRemarkItem?.version ?? 'missing'}`, OPTIONS)
const clearRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(remarkedItem.id)}/warehouse-remark`, {
warehouseRemark: ' ', expectedPlanVersion: Number(remarkedPlan.version),
expectedItemVersion: Number(remarkedItem.version), operationId: `${context.marker}-warehouse-remark-clear`.slice(0, 128),
})
const clearedPlan = context.apiSuccess(MODULE, 'EDITING 装柜明细空白仓库备注归一化为 null', clearRemarkResponse, '', OPTIONS)
const clearedItem = clearedPlan?.items?.find?.((item) => id(item.id) === id(remarkedItem.id))
context.assert(MODULE, '清空仓库备注不影响客户与内部备注来源', clearRemarkResponse,
clearedItem?.warehouseRemark == null && clearedItem?.customerRemark === orderRemarks.customerRemark
&& clearedItem?.internalRemark === orderRemarks.internalRemark,
`warehouseRemark=${clearedItem?.warehouseRemark ?? 'null'}`, OPTIONS)
if (!clearedItem?.id) return
const restoreRemarkResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${id(clearedItem.id)}/warehouse-remark`, {
warehouseRemark, expectedPlanVersion: Number(clearedPlan.version), expectedItemVersion: Number(clearedItem.version),
operationId: `${context.marker}-warehouse-remark-restore`.slice(0, 128),
})
const persistedRemarkPlan = context.apiSuccess(MODULE, '恢复真实仓库备注供装柜完成事件下发', restoreRemarkResponse, '', OPTIONS)
if (!Number.isInteger(Number(persistedRemarkPlan?.version))) return
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id)
@@ -187,7 +255,7 @@ export async function run(context) {
const pushRequest = Object.freeze({
path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`,
body: Object.freeze({ expectedVersion: Number(assignedLoadingPlan.version) }),
body: Object.freeze({ expectedVersion: Number(persistedRemarkPlan.version) }),
headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }),
})
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
@@ -919,6 +987,7 @@ export async function run(context) {
})
const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS)
const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId)
const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id))
const incrementedTaskResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}`)
const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS)
const incrementalTaskItem = incrementedTask?.items?.find?.((item) =>
@@ -933,6 +1002,8 @@ export async function run(context) {
&& Number(incrementedPlan?.pdaTaskVersion) > Number(taskBeforeIncrement?.version)
&& Number(incrementalPlanItem?.version) === 0 && Number(incrementalPlanItem?.pickedPackageCount) === 0
&& Number(incrementalPlanItem?.minPackageCount) === 1
&& incrementalPlanItem?.warehouseRemark == null
&& preservedRemarkItem?.warehouseRemark === warehouseRemark
&& incrementalPlanItem?.canAdjust === true && incrementalPlanItem?.canRemove === true
&& id(incrementedTask?.loadingPlanId) === loadingPlanId
&& id(incrementedTask?.containerBookingId) === containerBookingId
@@ -999,7 +1070,8 @@ export async function run(context) {
Number(adjustedPlan?.version) > Number(reassignedPlan?.version)
&& Number(adjustedPlan?.pdaTaskVersion) > Number(reassignedPlan?.pdaTaskVersion)
&& Number(adjustedItem?.version) > Number(reassignedPlanItem?.version)
&& Number(adjustedItem?.packageCount) === 2 && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
&& Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark
&& id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan
reassignedPlanItem = adjustedItem
@@ -4,25 +4,21 @@ import test from 'node:test'
const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8')
test('each remainder uses a simulation linked to a confirmed then released booking', () => {
test('each remainder uses a simulation linked to a released booking requirement', () => {
assert.match(source, /POST', CONTAINERS/)
assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/)
assert.match(source, /一次登记直接返回 CONFIRMED/)
assert.match(source, /订舱为 RELEASED 且不预生成真实柜/)
assert.doesNotMatch(source, /ocean-bookings\/\$\{oceanBookingId\}\/(?:submit|confirm)/)
const createPayload = source.slice(source.indexOf('function bookingPayload'), source.indexOf('async function loadOneFragment'))
assert.match(createPayload, /oceanBookingDraft\(\{ marker \}/)
assert.match(createPayload, /customerId: source\.tenantId/)
assert.match(createPayload, /channelCode: source\.channelCode/)
assert.match(source, /ocean-booking-link/)
assert.match(source, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(source, /bookingDetail\.oceanBooking\.status === 'CONFIRMED'/)
assert.match(source, /`\$\{bookingPath\}\/release`/)
assert.match(source, /oceanBookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/)
const releaseStart = source.indexOf('const releaseResponse')
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt',
'estimatedArrivalAt']) assert.match(releaseBlock, new RegExp(`${field}: persistedTransport\\.${field}`))
assert.match(releaseBlock, /expectedVersion: Number\(bookingDetail\.oceanBooking\.version\)/)
assert.doesNotMatch(releaseBlock, /BL-\$\{|SPLIT-VESSEL|SV-\$\{|2026-/)
assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/)
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
@@ -31,25 +27,25 @@ test('each remainder uses a simulation linked to a confirmed then released booki
assert.doesNotMatch(source, /candidates\?warehouseId=/)
})
test('release stops unless creation response and detail expose the same complete transport snapshot', () => {
test('release freezes and detail replays the same complete transport snapshot', () => {
assert.match(source, /const BOOKING_TRANSPORT_FIELDS = \[/)
assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingCreated\?\.oceanBooking\)/)
assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingConfirmed\?\.oceanBooking\)/)
assert.match(source, /const persistedTransport = bookingTransportSnapshot\(bookingDetail\?\.oceanBooking\)/)
assert.match(source, /completeBookingTransport\(createdTransport\)/)
assert.match(source, /sameBookingTransport\(createdTransport, persistedTransport\)/)
assert.match(source, /if \(!transportFrozen\) return null/)
})
test('PDA opens only after merging a registered real container and uses stable plan identity', () => {
test('PDA opens only after linking a released requirement and uses stable plan identity', () => {
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`")
const releaseIndex = source.indexOf("`${bookingPath}/release`")
const releaseIndex = source.indexOf("`/api/platform/v1/ocean-bookings/${oceanBookingId}/release`")
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`")
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(linkIndex >= 0 && releaseIndex > linkIndex && pushIndex > releaseIndex
assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /const realContainerNo = requirement\?\.containerNo/)
assert.match(source, /const realContainerNo = `SPLIT-/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
@@ -161,16 +161,20 @@ async function loadOneFragment(context, state) {
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
if (!oceanBookingId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
const bookingConfirmed = bookingCreated
context.assert(MODULE, `${sourceOrderNo} 一次登记直接返回 CONFIRMED`, bookingCreate,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED'
&& bookingConfirmed?.containers?.length === 1,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerType === '40HC')
const realContainerNo = requirement?.containerNo
if (!requirement?.containerBookingId || !realContainerNo) return null
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse,
bookingConfirmed?.oceanBooking?.status === 'RELEASED'
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC')
const realContainerNo = `SPLIT-${context.marker.replace(/\D/g, '').slice(-10)}-${sequence}`.slice(0, 64)
if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,
expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
@@ -231,7 +235,7 @@ async function loadOneFragment(context, state) {
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking)
const createdTransport = bookingTransportSnapshot(bookingConfirmed?.oceanBooking)
const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking)
const transportFrozen = completeBookingTransport(createdTransport)
&& completeBookingTransport(persistedTransport)
@@ -241,20 +245,6 @@ async function loadOneFragment(context, state) {
BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!transportFrozen) return null
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
expectedVersion: Number(bookingDetail.oceanBooking.version),
billOfLadingNo: persistedTransport.billOfLadingNo,
vesselName: persistedTransport.vesselName,
voyageNo: persistedTransport.voyageNo,
estimatedSailingAt: persistedTransport.estimatedSailingAt,
estimatedArrivalAt: persistedTransport.estimatedArrivalAt,
reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)
bookingDetailResponse = await context.request('GET', bookingPath)
bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的 RELEASED 订舱`, bookingDetailResponse, '', OPTIONS)
}
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
@@ -91,9 +91,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'无号且未关联订舱仍无 PDA 任务',
'模拟柜原地合并三期 CONFIRMED 真实订舱柜',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'三期订舱 RELEASED 冻结运输快照',
'模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号',
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
@@ -119,10 +118,9 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assert.match(value, /CONFIRMED 订舱回显完整且冻结五项运输快照[\s\S]*if \(!bookingId \|\| !requirement\?\.containerBookingId \|\| !frozenTransportMatches\) return[\s\S]*三期订舱携带已冻结运输快照并放舱/)
assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/)
assert.match(value, /RELEASED 订舱回显完整冻结运输快照且无真实柜号[\s\S]*if \(!bookingId \|\| !requirement\?\.id \|\| !frozenTransportMatches\) return/)
assert.doesNotMatch(value, /new Date\(Date\.now\(\)/)
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(value, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => {
@@ -236,7 +234,7 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
})
test('container loading arranges a simulation before linking a confirmed real booking', () => {
test('container loading arranges a simulation before linking a released booking requirement', () => {
const value = source('./phase3-container-loading.mjs')
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
assertOrder(value, [
@@ -255,26 +253,26 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(draft, new RegExp(`${field}:`))
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /confirmedBooking\.containers\.length === 2/)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(value, /confirmedBooking\.containerRequirements\.length === 2/)
assert.match(value, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(value, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.match(value, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(value, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
assert.match(value, /订舱 allocation 原子创建并指向稳定模拟柜身份/)
assert.match(value, /已消费订舱需求拒绝再次关联/)
assertOrder(value, [
"const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,",
"const linkedContainer = context.apiSuccess(MODULE, '首次关联 RELEASED 订舱需求并补真实柜号', linkResponse,",
'if (!linkedContainer) return',
"const afterLinkPlanResponse = await context.request('GET'",
"const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS",
])
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.doesNotMatch(value, /realContainerBookingId|MERGED/)
assert.match(value, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
@@ -284,7 +282,6 @@ test('container loading arranges a simulation before linking a confirmed real bo
assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)
assert.match(value, /POST', CONTAINERS/)
})
@@ -359,9 +356,8 @@ test('container loading enforces channel isolation and stable PDA identity acros
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'无号且未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
'订舱放舱为 RELEASED 且不预生成真实货柜',
'首次关联 RELEASED 订舱需求并补真实柜号',
'订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务',
'计划级确认前读取即时版本与就绪门禁',
'计划级原子确认整份预装并触发唯一 PDA 任务',
@@ -382,7 +378,7 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(confirmedBooking\?\.oceanBooking\?\.version\),\s*\.\.\.frozenTransport, reason: context\.marker/s)
assert.match(value, /expectedVersion: Number\(bookingCreatedDraft\?\.oceanBooking\?\.version\), reason: context\.marker/)
assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/)
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
assert.doesNotMatch(batch, /containerNo:/)
@@ -489,22 +485,15 @@ test('customs provisions released booking without pre-completing truck', () => {
assert.doesNotMatch(value, /customsAgentName: `代理-/)
})
test('downstream producers prefer the phase3Loading container fixture', () => {
test('downstream container consumers prefer the phase3Loading fixture', () => {
const ocean = source('./ocean-bookings.mjs')
const customs = source('./phase3-customs.mjs')
const serviceCenter = source('./service-center-flow.mjs')
assert.match(ocean, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/)
assert.match(ocean, /ocean-flow-release/)
assert.match(ocean, /existing\?\.marker === context\.marker && existing\?\.finalStatus === 'RELEASED'/)
assert.match(ocean, /const reconciled = await releaseLoadingBooking\(context, containerFlow, suffix\)/)
assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/)
assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/)
assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/)
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/)
assert.match(serviceCenter, /customerId, customerName, carrierId: null/)
@@ -155,3 +155,14 @@ test('default HTTP entry carries independent truck positives and association neg
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/)
})
test('dispatch requires bill of lading number and active BILL_OF_LADING attachment', () => {
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
assert.match(source, /attachmentType=BILL_OF_LADING/)
assert.match(source, /billOfLadingNo:/)
assert.match(source, /DISPATCH 缺提单号与文件明确拒绝/)
assert.match(source, /派车前保留有效 BILL_OF_LADING 提单附件/)
const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8')
assert.match(openapi, /DISPATCH requires a nonblank billOfLadingNo/)
assert.match(openapi, /BILL_OF_LADING attachment/)
})
@@ -26,7 +26,7 @@ async function request(context, method, path, body, headers = {}) {
}
async function uploadAttachment(context, appointmentId) {
const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=ACCEPTANCE_EVIDENCE`
const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck.png`)
@@ -89,6 +89,8 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null,
customerId: identity.customerId, customerName: identity.customerName || null,
carrierId: supplierId || null, carrierName: supplierName || null,
billOfLadingNo: Object.hasOwn(associations, 'billOfLadingNo')
? associations.billOfLadingNo : `BL-TRUCK-${suffix}`.slice(0, 128),
fleetContact: '车队联系人', fleetPhone: '13800000004',
driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`,
storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址',
@@ -155,6 +157,21 @@ export async function run(context) {
`HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS)
}
const missingBillCreate = await request(context, 'POST', BASE,
truckAppointmentPayload(context, `${suffix}B`, customerIdentity, 0, { billOfLadingNo: null }))
const missingBill = data(missingBillCreate)
if (missingBill?.id) {
const missingBillSubmit = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/submit`,
{ expectedVersion: Number(missingBill.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-submit`.slice(0, 128) })
const submitted = data(missingBillSubmit)
const rejectedDispatch = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/dispatch`,
{ expectedVersion: Number(submitted?.version), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-truck-no-bill-dispatch`.slice(0, 128) })
context.addStep(MODULE, '拖车 DRAFT 可无提单但 DISPATCH 缺提单号与文件明确拒绝', rejectedDispatch,
failure(rejectedDispatch), `HTTP=${rejectedDispatch.http}, code=${codeOf(rejectedDispatch)}`, OPTIONS)
}
const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading]
.find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId))
if (linkedFlow) {
@@ -204,20 +221,14 @@ export async function run(context) {
const upload = await uploadAttachment(context, appointmentId); const attachment = context.apiSuccess(MODULE, '上传本轮拖车预约图片附件', upload, `fileName=${context.marker}-truck.png`, OPTIONS)
const attachmentId = id(attachment?.id)
context.assert(MODULE, '附件引用归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'ACCEPTANCE_EVIDENCE' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS)
context.assert(MODULE, '提单附件归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'BILL_OF_LADING' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS)
const attachments = await request(context, 'GET', `${path}/attachments`); const attachmentList = context.apiSuccess(MODULE, '查询拖车预约附件列表', attachments, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS)
const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'ACCEPTANCE_EVIDENCE', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' })
const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'BILL_OF_LADING', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' })
context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
if (attachmentId) {
const removed = await request(context, 'DELETE', `${path}/attachments/${attachmentId}`)
context.apiSuccess(MODULE, '删除本轮拖车预约附件引用', removed, `attachmentId=${attachmentId}`, OPTIONS)
const afterRemoval = await request(context, 'GET', `${path}/attachments`)
const remainingAttachments = context.apiSuccess(MODULE, '删除后回查拖车预约附件列表', afterRemoval, '', OPTIONS)
context.assert(MODULE, '删除后附件引用不再返回', afterRemoval,
Array.isArray(remainingAttachments) && !remainingAttachments.some((item) => id(item.id) === attachmentId),
`attachmentId=${attachmentId}, remaining=${remainingAttachments?.length ?? 'n/a'}`, OPTIONS)
}
context.assert(MODULE, '派车前保留有效 BILL_OF_LADING 提单附件', attachments,
Boolean(attachmentId) && attachmentList.some((item) => id(item.id) === attachmentId && item.attachmentType === 'BILL_OF_LADING'),
`attachmentId=${attachmentId || 'missing'}`, OPTIONS)
let version = Number(appointment.version)
const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version)
+27 -19
View File
@@ -46,21 +46,18 @@ test('test-environment full acceptance executes warehouse-scoped booking channel
assert.match(fixture, /createdResources\.platformLogisticsChannels/)
})
test('test-environment full acceptance keeps assigned and unassigned customer booking chains', () => {
test('test-environment full acceptance keeps the DRAFT to RELEASED booking chain', () => {
const loadingIndex = adminAcceptance.indexOf("'phase3-container-loading'")
const bookingIndex = adminAcceptance.indexOf("'ocean-bookings'")
assert.ok(loadingIndex >= 0 && bookingIndex > loadingIndex,
'ocean-bookings must run after the assigned-customer container fixture')
const bookingModule = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'ocean-bookings.mjs'), 'utf8')
assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/)
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containers\?\.length === 1/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(bookingModule, /创建订舱 DRAFT(不生成真实柜号\/货柜分配)/)
assert.match(bookingModule, /containerRequirements/)
assert.match(bookingModule, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(bookingModule, /RELEASED 编辑拒绝且零写入/)
assert.doesNotMatch(bookingModule, /realContainerBookingId|CHANGE_PENDING|CANCELLED/)
})
test('test-environment loading chains push without a number and open PDA only after all prerequisites', () => {
@@ -69,32 +66,33 @@ test('test-environment loading chains push without a number and open PDA only af
const bridgeLoading = readFileSync(
resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(containerLoading, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.doesNotMatch(containerLoading, /realContainerBookingId/)
const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const linkIndex = bridgeLoading.indexOf('模拟柜原地合并三期 CONFIRMED 真实订舱柜')
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
const releaseIndex = bridgeLoading.indexOf('三期订舱 RELEASED 冻结运输快照')
const linkIndex = bridgeLoading.indexOf('模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
assert.ok(pushIndex >= 0 && absentIndex > pushIndex
&& linkIndex > absentIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
&& releaseIndex > absentIndex && linkIndex > releaseIndex && confirmIndex > linkIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/)
assert.match(bridgeLoading, /loading-plans\/\$\{loadingPlanId\}\/confirm/)
assert.match(bridgeLoading, /readiness\?\.readyForPda === true/)
assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
const containerReleaseIndex = containerLoading.indexOf('订舱放舱为 RELEASED 且不预生成真实货柜')
const containerLinkIndex = containerLoading.indexOf('首次关联 RELEASED 订舱需求并补真实柜号')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
assert.ok(containerPushIndex >= 0 && containerLinkIndex > containerPushIndex
&& containerReleaseIndex > containerLinkIndex
&& containerConfirmIndex > containerReleaseIndex
assert.ok(containerPushIndex >= 0 && containerReleaseIndex > containerPushIndex
&& containerLinkIndex > containerReleaseIndex
&& containerConfirmIndex > containerLinkIndex
&& containerPdaIndex > containerConfirmIndex)
assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoading, /readiness\?\.readyForPda === true/)
@@ -102,6 +100,16 @@ test('test-environment loading chains push without a number and open PDA only af
assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
})
test('default loading chain carries the three loading-list remarks contract', () => {
const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8')
assert.match(adminAcceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'phase3-loading'/s)
assert.match(loading, /装柜明细客户备注与最新内部备注来源精确且不冒用货物备注/)
assert.match(loading, /warehouse-remark/)
assert.match(loading, /仓库备注相同 operationId 与原请求精确重放/)
assert.match(loading, /仓库备注旧版本冲突后版本与备注事实零漂移/)
assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/)
})
test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => {
const focused = normalizeModuleOrder([
'merchant-qualification-fixture', 'orders', 'inbound-measurement',