feat: enforce canonical container identity across regions
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user