feat: enforce canonical container identity across regions

This commit is contained in:
2026-08-31 12:13:20 +08:00
parent b386f35ab4
commit 942dbace19
78 changed files with 1203 additions and 730 deletions
@@ -7,6 +7,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) }
function code(response) { return id(response?.body?.code) || 'missing' }
@@ -205,7 +206,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
evidence(detailResponse), OPTIONS)
const containerNo = `CL${suffix}${index + 1}`.slice(0, 64)
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo,
expectedVersion: Number(detail?.version),
@@ -0,0 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
test('HTTP fixtures generate stable unique ISO 6346 container numbers', () => {
const first = iso6346ContainerNumber('fixture-a')
assert.match(first, /^[A-Z]{3}U[0-9]{7}$/)
assert.equal(first, iso6346ContainerNumber('fixture-a'))
assert.notEqual(first, iso6346ContainerNumber('fixture-b'))
})
@@ -0,0 +1,18 @@
import { createHash } from 'node:crypto'
const LETTER_VALUES = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
export function iso6346ContainerNumber(seed, ownerCode = 'TST') {
if (!/^[A-Z]{3}$/.test(ownerCode)) throw new Error('ownerCode must contain three uppercase letters')
const serial = String(createHash('sha256').update(String(seed)).digest().readUInt32BE(0) % 1_000_000)
.padStart(6, '0')
const body = `${ownerCode}U${serial}`
let sum = 0
for (let index = 0; index < body.length; index += 1) {
const code = body.charCodeAt(index)
const value = code >= 48 && code <= 57 ? code - 48 : LETTER_VALUES[code - 65]
sum += value * (2 ** index)
}
return `${body}${(sum % 11) % 10}`
}
@@ -19,7 +19,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`')
const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`')
const transportIndex = source.indexOf('ocean-booking-link')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex)
const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex)
@@ -34,7 +34,8 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/)
assert.match(source, /计划确认同键同参精确重放/)
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/)
assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/)
assert.match(source, /iso6346ContainerNumber/)
assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/)
assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/)
assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/)
@@ -126,7 +126,7 @@ async function readInbound(context, fixture) {
async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label)
const containerNo = `MAN-${value}`.slice(0, 64)
const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`)
const summary = source.detail.summary
const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
@@ -201,18 +201,7 @@ async function createPlanTask(context, label) {
expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`),
})
plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS)
const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS)
const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64)
const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version),
reason: `${context.marker} 人工状态样本`,
})
const transported = context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS)
if (transported?.containerNo !== containerNo || id(transported?.id) !== containerId
|| transported?.cabinetNo !== container?.cabinetNo) {
throw new Error(`${label} 真实集装箱号未绑定到稳定柜次`)
}
const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`)
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`,
{ expectedVersion: Number(plan.version) }, `${label}-push`)
plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS)
@@ -484,3 +473,4 @@ export async function run(context, matrix = context.createdResources.manualState
await completeSimpleDocuments(context, completed, matrix)
}
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -2,6 +2,7 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -146,7 +147,7 @@ export async function run(context) {
if (!selectedChannel) return
const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
@@ -472,12 +473,6 @@ export async function run(context) {
context.addStep(MODULE, '已消费订舱需求拒绝再次关联', duplicateLinkResponse,
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
expectedVersion: Number(linkedContainer.version), reason: context.marker,
})
context.addStep(MODULE, '已有排货后拒绝解绑或换绑', unlinkAfterLoadingResponse,
failure(unlinkAfterLoadingResponse), `HTTP=${unlinkAfterLoadingResponse.http}, code=${codeOf(unlinkAfterLoadingResponse)}`, OPTIONS)
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)
@@ -576,22 +571,24 @@ export async function run(context) {
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
beforeReplacementSnapshotResponse, '', OPTIONS)
const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64)
const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`,
})
const replacementContainerNo = iso6346ContainerNumber(`${context.marker}-replacement`)
const replacementResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(confirmedPlan?.version), operationReason: `${context.marker}-replace-container-no`,
}, { 'Idempotency-Key': `${context.marker}-replace-container-no`.slice(0, 128) })
const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS)
context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse,
replaced?.containerNo === replacementContainerNo,
`containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS)
replaced?.summary?.containerNo === replacementContainerNo,
`containerNo=${replaced?.summary?.containerNo || 'missing'}`, OPTIONS)
const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64),
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`,
})
const clearContainerNoResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: ' ', voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(replaced?.summary?.version), operationReason: `${context.marker}-clear-container-no`,
}, { 'Idempotency-Key': `${context.marker}-clear-container-no`.slice(0, 128) })
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
clearContainerNoResponse.http === 200 && ['A0409', '409'].includes(codeOf(clearContainerNoResponse)),
clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)),
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
const replacedTaskResponse = await context.request('GET',
@@ -644,6 +641,15 @@ export async function run(context) {
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
const claimedCorrectionResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: iso6346ContainerNumber(`${context.marker}-claimed-mutate`), voyageNo: 'CLAIMED',
expectedVersion: Number(claimedPlan?.version), operationReason: `${context.marker}-claimed-mutate`,
}, { 'Idempotency-Key': `${context.marker}-claimed-mutate`.slice(0, 128) })
context.addStep(MODULE, 'PDA任务领取后真实柜号纠错被拒绝', claimedCorrectionResponse,
claimedCorrectionResponse.http === 200 && ['A0409', '409'].includes(codeOf(claimedCorrectionResponse)),
`HTTP=${claimedCorrectionResponse.http}, code=${codeOf(claimedCorrectionResponse)}`, OPTIONS)
const inventoryResponse = await context.request('GET',
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
@@ -810,7 +816,7 @@ export async function run(context) {
const immutableResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`,
{ containerNo: `MUTATE-${suffix}`.slice(0, 64), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker },
{ containerNo: iso6346ContainerNumber(`${context.marker}-completed-mutate`), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker },
{ 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) })
context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse,
immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)),
@@ -79,6 +79,7 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', (
test('existing container number replacement keeps container plan and PDA identities stable', () => {
assert.match(containerLoadingAcceptance, /replacementContainerNo/)
assert.match(containerLoadingAcceptance, /PDA任务领取后真实柜号纠错被拒绝/)
assert.match(containerLoadingAcceptance, /containerBookingId: containerId/)
assert.match(containerLoadingAcceptance, /loadingPlanId: planId/)
assert.match(containerLoadingAcceptance, /pdaTaskId: taskId/)
@@ -169,7 +169,7 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = markerSuffix(context.marker)
const warehouseId = id(initial?.summary?.warehouseId)
const containerNo = `P3B-${suffix}`.slice(0, 64)
const containerNo = iso6346ContainerNumber(`${context.marker}-phase3-loading`)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const supplierResponse = await context.request('GET',
@@ -1387,3 +1387,4 @@ export async function run(context) {
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
}
}
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -45,7 +45,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(releaseIndex >= 0 && linkIndex > releaseIndex && pushIndex > linkIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /const realContainerNo = `SPLIT-/)
assert.match(source, /const realContainerNo = iso6346ContainerNumber/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
@@ -8,6 +8,7 @@ const SMART = '/api/platform/v1/first-leg/smart-loading'
const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -206,7 +207,7 @@ async function loadOneFragment(context, state) {
&& (!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)
const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), containerNo: realContainerNo,