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