This commit is contained in:
2026-08-17 13:24:48 +08:00
parent ac6cf9c904
commit 6739c83467
43 changed files with 1049 additions and 315 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-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`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-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-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` | `truck-appointments-contract.test.mjs` |
@@ -31,28 +31,42 @@ test('simulation creation uses server-owned identity and formal master data', ()
assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/)
})
test('first link and unlink gates are intentionally asymmetric', () => {
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, /WAIT_LOADING && value != SmartLoadingStatus\.EDITING/)
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(containerService, /requireUnlinkMutable\(container\)/)
assert.match(containerService, /status\(container\) != SmartLoadingStatus\.WAIT_LOADING \|\| repository\.hasActiveLoadingItems/)
assert.match(firstlegOpenApi, /首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜/)
assert.match(firstlegOpenApi, /解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜/)
assert.match(firstlegOpenApi, /realContainerBookingId/)
assert.match(firstlegOpenApi, /原真实柜置 MERGED 且不再出现在正常货柜列表/)
})
test('real booking association locks scope capacity and stable internal identity', () => {
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, /真实订舱柜箱型分配已关联/)
assert.match(containerService, /setOceanBookingId[\s\S]*setOceanBookingRequirementId/)
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('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 = [
'新增内部模拟柜',
@@ -64,9 +78,10 @@ test('HTTP fixture follows simulation arrange link PDA sequence without auto-con
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, /同一真实订舱箱型容量拒绝重复关联/)
assert.match(loadingFixture, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/)
assert.match(loadingFixture, /空模拟柜解绑后允许重新关联/)
assert.match(loadingFixture, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(loadingFixture, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/)
})
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
const productionFixtures = [
'manual-state-loading-pda.mjs',
'ocean-booking-fixture.mjs',
'ocean-bookings.mjs',
'phase3-carrier-schedule-queries.mjs',
'phase3-container-loading.mjs',
'phase3-loading.mjs',
'phase3-order-splitting.mjs',
'phase3-port-schedules.mjs',
]
function source(name) {
return readFileSync(new URL(name, import.meta.url), 'utf8')
}
test('HTTP fixtures use the canonical primary and mismatch container specifications', () => {
const primarySources = productionFixtures.map(source).join('\n')
const containerFlow = source('phase3-container-loading.mjs')
assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/)
assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/)
})
test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => {
for (const name of productionFixtures) {
const fixture = source(name)
assert.doesNotMatch(fixture, /40HQ|45HQ/, name)
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name)
assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name)
}
})
test('the frozen catalog is complete and unambiguous', () => {
// Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg.
const catalog = Object.freeze({
'20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }),
'40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }),
'40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }),
})
assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC'])
assert.equal(new Set(Object.keys(catalog)).size, 5)
})
@@ -97,11 +97,12 @@ async function readInbound(context, fixture) {
async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label)
const containerNo = `MAN-${value}`.slice(0, 64)
const summary = source.detail.summary
const draft = {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
warehouseName: summary.warehouseName, billOfLadingNo: `BL-${value}`.slice(0, 128),
billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
@@ -112,12 +113,12 @@ async function createConfirmedContainer(context, source, label) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68,
maxWeightKg: 28000, quantity: 1 }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 28000,
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -130,10 +131,10 @@ async function createConfirmedContainer(context, source, label) {
context.assert(MODULE, `${label} 一次登记直接返回 CONFIRMED`, createdResponse,
confirmed?.oceanBooking?.status === 'CONFIRMED',
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) throw new Error(`${label} 确认订舱未返回箱型需求`)
const requirement = confirmed?.containers?.find?.((item) => item.containerNo === containerNo)
if (!requirement?.containerBookingId) throw new Error(`${label} 确认订舱未返回真实柜身份`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS)
if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) {
@@ -22,7 +22,7 @@ function oceanPayload(context, template, index) {
return { expectedVersion: 0, data: {
customerId: source.customerId, customerName: source.customerName,
channelCode: source.channelCode,
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
warehouseId: source.warehouseId,
billOfLadingNo: `BL-${suffix(context, index)}`, vesselName: `人工轮-${suffix(context, index)}`,
voyageNo: `V${suffix(context, index)}`,
shippingCompanyId: source.shippingCompanyId,
@@ -36,8 +36,9 @@ function oceanPayload(context, template, index) {
remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
({ partyType, partyName, address, contactName, contactPhone })),
containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) =>
({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })),
containers: template.containers.map(({ containerType, containerKind, capacityCbm, maxWeightKg }, containerIndex) =>
({ containerNo: `MAN-${suffix(context, index)}-${String(containerIndex + 1).padStart(2, '0')}`.slice(0, 64),
containerType, containerKind, capacityCbm, maxWeightKg })),
} }
}
@@ -138,8 +139,8 @@ export async function run(context, matrix = context.createdResources.manualState
if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源')
const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`)
const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS)
if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求')
if (!template?.oceanBooking || !template?.parties?.length || !template?.containers?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或真实柜明细')
}
matrix.pages.oceanBookings = { requiredStatuses: OCEAN_LIST_STATUSES, fixtures: {} }
for (const [index, status] of OCEAN_STATUSES.entries()) {
@@ -31,23 +31,25 @@ test('ocean booking fixture blocks creation instead of inventing missing master
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('shared booking data omits server-owned channelName and includes offline-confirmed transport snapshots', () => {
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', 'warehouseName']) {
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId']) {
assert.ok(draft[key], `${key} is required`)
}
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.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
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')
@@ -78,8 +80,22 @@ test('shared booking draft can omit customer identity without inventing placehol
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.channelCode, 'SEA-US')
assert.equal(draft.warehouseId, '41')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
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 () => {
@@ -106,18 +122,22 @@ test('platform channel producer queries the selected origin warehouse and publis
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'],
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean'],
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate'],
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment'],
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'"],
['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] of regions) {
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)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(region, new RegExp(`${field}\\s*:`), `${name} must register ${field}`)
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')
@@ -140,5 +160,5 @@ test('all independent ocean booking producers use the shared master-data contrac
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName',
'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet)
"containerType: '40HC'", 'capacityCbm: 76', 'maxWeightKg: 28000']) assert.match(container, new RegExp(snippet), snippet)
})
@@ -53,7 +53,12 @@ export async function resolveOceanBookingMaster(context, module) {
}
export function oceanBookingDraft(context, suffix, master, labels = {}) {
const { channelName: _readOnlyChannelName, ...writableMaster } = master
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
const containers = labels.containers || [{
containerNo: `TST-${normalizedSuffix}-01`.slice(0, 64),
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
}]
return {
...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
@@ -70,7 +75,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
containers,
}
}
@@ -16,7 +16,15 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
async function createBooking(context, suffix, key, master) {
const containers = [
{ containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
return request(context, 'POST', BASE,
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { containers }) },
{ 'Idempotency-Key': key })
}
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
async function createConfirmedUnassignedBooking(context, suffix, master) {
@@ -37,10 +45,10 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
const detailResponse = await request(context, 'GET', path)
const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS)
const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo)
context.assert(MODULE, '未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜', detailResponse,
context.assert(MODULE, '未指定客户订舱保持空客户且原子生成一条真实柜', detailResponse,
detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null
&& detail?.oceanBooking?.customerName == null && detail?.containerAllocations?.length === 0 && Boolean(bookingNo),
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, allocations=${detail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& detail?.oceanBooking?.customerName == null && detail?.containers?.length === 1 && Boolean(bookingNo),
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS)
const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS)
@@ -48,7 +56,8 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
page?.records?.some?.((item) => id(item.id) === bookingId && item.customerId == null && item.customerName == null),
`bookingId=${bookingId}, bookingNo=${bookingNo}, records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
const resource = { id: bookingId, marker: context.marker, customerId: null, customerName: null,
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId: null,
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId,
containerBookingId: id(detail?.containers?.[0]?.containerBookingId) || null,
finalStatus: detail?.oceanBooking?.status || null }
context.createdResources.oceanBookingUnassignedCustomer = resource
return resource
@@ -62,11 +71,11 @@ async function releaseLoadingBooking(context, containerFlow, suffix) {
let flow = data(flowResponse)
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
const allocationMatches = flow?.containerAllocations?.some?.((item) =>
const allocationMatches = flow?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse,
allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId),
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, containers=${flow?.containers?.length ?? 'n/a'}`, OPTIONS)
if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') {
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, flow?.oceanBooking?.[field]]))
@@ -84,7 +93,7 @@ async function releaseLoadingBooking(context, containerFlow, suffix) {
}
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
flow?.oceanBooking?.status === 'RELEASED'
&& flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
&& flow?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
`status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? {
id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker,
@@ -114,6 +123,24 @@ export async function run(context) {
}
const page = context.apiSuccess(MODULE, '订舱列表分页查询', list, '', OPTIONS)
context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
const duplicateNo = `TST-${suffix}-DUP`.slice(0, 64)
const duplicateDraft = oceanBookingDraft(context, `${suffix}D`, master, { containers: [
{ containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
] })
const duplicateCreate = await request(context, 'POST', BASE, { expectedVersion: 0, data: duplicateDraft },
{ 'Idempotency-Key': `${context.marker}-ocean-duplicate-in-request`.slice(0, 128) })
step(context, '同一订舱请求重复真实柜号原子拒绝', duplicateCreate, failure(duplicateCreate),
`containerNo=${duplicateNo}, HTTP=${duplicateCreate.http}, code=${codeOf(duplicateCreate)}`)
const duplicateListResponse = await request(context, 'GET', `${BASE}?page=1&size=5`)
const duplicatePage = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的订舱列表', duplicateListResponse, '', OPTIONS)
const duplicateContainerResponse = await request(context, 'GET',
`/api/platform/v1/containers?page=1&size=20&keyword=${encodeURIComponent(duplicateNo)}`)
const duplicateContainers = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的货柜列表', duplicateContainerResponse, '', OPTIONS)
context.assert(MODULE, '请求内重复柜号失败后订舱和货柜零写入', duplicateContainerResponse,
Number(duplicatePage?.total) === Number(page?.total)
&& Array.isArray(duplicateContainers?.records) && duplicateContainers.records.length === 0,
`bookingTotalBefore=${page?.total ?? 'n/a'}, bookingTotalAfter=${duplicatePage?.total ?? 'n/a'}, containers=${duplicateContainers?.records?.length ?? 'n/a'}`, OPTIONS)
const statuses = ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']
for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) }
for (const status of ['DRAFT', 'SUBMITTED']) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); step(context, `正式订舱列表拒绝 ${status} 状态`, r, failure(r), `HTTP=${r.http}, code=${codeOf(r)}`) }
@@ -126,18 +153,39 @@ export async function run(context) {
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse,
Boolean(bookingId) && detail?.oceanBooking?.status === 'CONFIRMED'
&& detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1
&& detail?.parties?.length === 3 && detail?.containers?.length === 2
&& ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
.every((field) => Boolean(detail?.oceanBooking?.[field])),
`bookingId=${bookingId || 'missing'}, status=${detail?.oceanBooking?.status || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
`bookingId=${bookingId || 'missing'}, status=${detail?.oceanBooking?.status || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)]
context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse,
detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => {
const item = detail.containers.find((value) => value.containerNo === containerNo)
return Boolean(item?.containerBookingId && item?.cabinetNo && item?.requirementId && item?.allocationId)
&& id(item?.warehouseId) === id(master.warehouseId) && item?.channelCode === master.channelCode
&& id(item?.customerId) === id(master.customerId)
}),
`expected=${expectedContainerNos.join('|')}, actual=${detail?.containers?.map?.((item) => item.containerNo).join('|') || 'none'}`, OPTIONS)
if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' }
const replay = await createBooking(context, suffix, key, master)
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
const existingContainerNo = detail?.containers?.[0]?.containerNo
const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{
containerNo: existingContainerNo, containerType: detail?.containers?.[0]?.containerType,
containerKind: detail?.containers?.[0]?.containerKind, capacityCbm: detail?.containers?.[0]?.capacityCbm,
maxWeightKg: detail?.containers?.[0]?.maxWeightKg,
}] })
const globalDuplicate = await request(context, 'POST', BASE,
{ expectedVersion: 0, data: globalDuplicateDraft },
{ 'Idempotency-Key': `${context.marker}-ocean-global-duplicate`.slice(0, 128) })
step(context, '数据库全局重复真实柜号原子拒绝', globalDuplicate,
Boolean(existingContainerNo) && failure(globalDuplicate),
`containerNo=${existingContainerNo || 'missing'}, HTTP=${globalDuplicate.http}, code=${codeOf(globalDuplicate)}`)
const path = `${BASE}/${bookingId}`
let version = Number(detail.oceanBooking.version)
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
@@ -164,13 +212,15 @@ export async function run(context) {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
const requirement = changeConfirmed?.containerRequirements?.find?.((item) => item?.containerType === '40HQ')
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
context.assert(MODULE, '独立订舱生命周期不占用本轮已装货柜次', changed,
changeConfirmed?.containerAllocations?.length === 0 && Number.isInteger(version),
`allocations=${changeConfirmed?.containerAllocations?.length ?? 'n/a'}, version=${version}`, OPTIONS)
const realContainer = changeConfirmed?.containers?.find?.((item) => item?.containerType === '40HC')
context.assert(MODULE, '独立订舱存在完整匹配柜型的真实柜身份', changed,
Boolean(realContainer?.containerBookingId && realContainer?.containerNo && realContainer?.requirementId && realContainer?.allocationId),
`containerBookingId=${id(realContainer?.containerBookingId) || 'missing'}`, OPTIONS)
if (!realContainer?.containerBookingId || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
context.assert(MODULE, '独立订舱真实柜不占用本轮已装货柜次身份', changed,
!changeConfirmed?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow?.containerBookingId))
&& Number.isInteger(version),
`containers=${changeConfirmed?.containers?.length ?? 'n/a'}, version=${version}`, OPTIONS)
}
const released = await request(context, 'POST', `${path}/release`, {
expectedVersion: version, ...frozenTransport, reason: context.marker,
@@ -182,8 +232,8 @@ export async function run(context) {
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName)
&& fd?.containerAllocations?.length === 0,
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& fd?.containers?.length === 2,
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, containers=${fd?.containers?.length ?? 'n/a'}`, OPTIONS)
let downstreamBooking = {
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
containerBookingId: allocatedContainerBookingId || null,
@@ -42,7 +42,6 @@ async function request(context, method, path, body, headers = {}) {
function oceanDraft(context, suffix, master) {
return {
...oceanBookingDraft(context, suffix, master),
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -52,7 +51,6 @@ function oceanDraft(context, suffix, master) {
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
@@ -112,10 +112,12 @@ export async function run(context) {
OPTIONS)
if (!selectedChannel) return
const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const bookingDraft = {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId, warehouseName,
channelCode, warehouseId,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null,
@@ -127,8 +129,10 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containerRequirements: [primaryRequirement,
{ containerType: '20GP', containerKind: 'FCL', capacityCbm: 28, maxWeightKg: 22000, quantity: 1 }],
containers: [
{ containerNo: realContainerNo, ...primaryRequirement },
{ containerNo: wrongContainerNo, containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
],
}
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
@@ -324,30 +328,16 @@ export async function run(context) {
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const realContainerNo = `TST-${suffix}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(postPushPlan.version), operationReason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse,
transported?.summary?.containerNo === realContainerNo
&& id(transported?.summary?.planId) === planId
&& id(transported?.summary?.containerBookingId) === containerId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, containerBookingId=${id(transported?.summary?.containerBookingId) || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== realContainerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404'
context.addStep(MODULE, '已补号但未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
context.addStep(MODULE, '无号且未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS)
if (!unlinkedTaskAbsent) return
const transportedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const transportedContainer = context.apiSuccess(MODULE, '预装补号后读取模拟柜最新版本', transportedContainerResponse, '', OPTIONS)
if (!Number.isInteger(Number(transportedContainer?.version))) return
const simulationBeforeLinkResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const simulationBeforeLink = context.apiSuccess(MODULE, '预装确认前读取模拟柜最新版本', simulationBeforeLinkResponse, '', OPTIONS)
if (!Number.isInteger(Number(simulationBeforeLink?.version))) return
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
@@ -363,8 +353,8 @@ export async function run(context) {
&& String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate,
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
&& Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& Array.isArray(confirmedBooking?.containers) && confirmedBooking.containers.length === 2,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, containers=${confirmedBooking?.containers?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate,
frozenTransportMatches,
transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS)
@@ -376,50 +366,18 @@ export async function run(context) {
records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'CONFIRMED'),
`bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS)
const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType)
const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType)
if (!requirement?.id || !wrongTypeRequirement?.id) return
const requirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
const wrongTypeRequirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === wrongContainerNo)
if (!requirement?.containerBookingId || !wrongTypeRequirement?.containerBookingId) return
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(transportedContainer.version),
realContainerBookingId: id(wrongTypeRequirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
})
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
const emptySimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
let emptySimulation = context.apiSuccess(MODULE, '新增空模拟柜验证解绑换绑', emptySimulationResponse, '', OPTIONS)
const emptyContainerId = id(emptySimulation?.id)
if (!emptyContainerId) return
const emptyLink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version),
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜首次关联真实订舱', emptyLink,
responseEvidence(emptyLink), OPTIONS)
if (!emptySimulation) return
const emptyUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
expectedVersion: Number(emptySimulation.version), reason: context.marker,
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜允许解除关联', emptyUnlink,
responseEvidence(emptyUnlink), OPTIONS)
if (!emptySimulation) return
const emptyRelink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version),
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink,
responseEvidence(emptyRelink), OPTIONS)
if (!emptySimulation) return
const emptyFinalUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
expectedVersion: Number(emptySimulation.version), reason: `${context.marker}-release-capacity`,
})
const emptyReleased = context.apiSuccess(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink,
responseEvidence(emptyFinalUnlink), OPTIONS)
if (!emptyReleased) return
const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(transportedContainer.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
})
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
responseEvidence(linkResponse), OPTIONS)
@@ -429,19 +387,38 @@ export async function run(context) {
const afterLinkItems = JSON.stringify(afterLinkPlan?.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
context.assert(MODULE, '关联前后内部柜次、计划和排货明细身份稳定', linkResponse,
id(linkedContainer?.id) === containerId && linkedContainer?.cabinetNo === simulation.cabinetNo
&& id(linkedContainer?.oceanBookingId) === bookingId && id(afterLinkPlan?.id) === planId
&& id(linkedContainer?.oceanBookingId) === bookingId && linkedContainer?.containerNo === realContainerNo
&& id(afterLinkPlan?.id) === planId
&& beforeLinkItems === afterLinkItems,
`containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || 'missing'}`, OPTIONS)
const activeContainersResponse = await context.request('GET',
`${CONTAINERS}?page=1&size=50&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '合并后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).filter((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId
&& !records(activeContainers).some((item) => id(item?.id) === id(requirement.containerBookingId)),
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}, mergedSourceId=${id(requirement.containerBookingId)}`, OPTIONS)
const mergedBookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
const mergedBooking = context.apiSuccess(MODULE, '合并后回查订舱真实柜当前操作身份', mergedBookingResponse, '', OPTIONS)
const mergedContainer = mergedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '订舱真实柜 allocation 已原子改指稳定模拟柜身份', mergedBookingResponse,
id(mergedContainer?.containerBookingId) === containerId
&& id(mergedContainer?.requirementId) === id(requirement.requirementId)
&& id(mergedContainer?.allocationId) === id(requirement.allocationId),
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
const duplicateLinkResponse = await request(context, 'POST', `${CONTAINERS}/${id(duplicateSimulation?.id)}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(duplicateSimulation?.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(duplicateSimulation?.version),
})
context.addStep(MODULE, '同一真实订舱箱型容量拒绝重复关联', duplicateLinkResponse,
context.addStep(MODULE, '已消费 MERGED 真实柜拒绝再次关联', duplicateLinkResponse,
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
@@ -464,13 +441,14 @@ export async function run(context) {
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId)
&& 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(containerNo)}&page=1&size=20`)
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
@@ -486,7 +464,7 @@ export async function run(context) {
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
context.assert(MODULE, '真实柜合并放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
@@ -85,10 +85,11 @@ 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, transport, release and plan-level confirmation', () => {
test('PDA task opens only after no-number push, real-container merge, release and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(containerLoadingAcceptance, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoadingAcceptance, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
@@ -120,11 +120,12 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const warehouseId = id(initial?.summary?.warehouseId)
const containerNo = `P3B-${suffix}`.slice(0, 64)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const bookingDraft = {
customerId: tenantId, customerName: initial?.summary?.customerName,
warehouseId, warehouseName: initial?.summary?.warehouseName,
warehouseId,
channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
@@ -135,12 +136,12 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
maxWeightKg: 100000, quantity: 1 }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HQ',
capacityCbm: 100, maxWeightKg: 100000,
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
const containerBookingId = id(simulation?.id)
@@ -159,7 +160,6 @@ export async function run(context) {
`candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
if (!candidate?.inboundDetailId || !warehouseId) return
const containerNo = `P3B-${suffix}`
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
bookingId: containerBookingId, remark: context.marker,
@@ -278,21 +278,9 @@ export async function run(context) {
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const transportResponse = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(postPushPlan.version),
operationReason: `${context.marker}-bind-container-no`,
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse,
transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId
&& id(transported?.summary?.containerBookingId) === containerBookingId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== containerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '已补号但未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404',
`HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS)
@@ -306,18 +294,18 @@ export async function run(context) {
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerNo === containerNo)
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.containerBookingId)
&& bookingConfirmed?.containers?.length === 1,
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
if (!bookingId || !requirement?.containerBookingId || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`)
const latestContainer = context.apiSuccess(MODULE, '补号后读取模拟柜最新版本用于订舱关联', latestContainerResponse, '', OPTIONS)
const latestContainer = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS)
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(latestContainer?.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(latestContainer?.version),
})
const linked = context.apiSuccess(MODULE, '预装补号后关联三期 CONFIRMED 订舱', linkResponse, '', OPTIONS)
const linked = context.apiSuccess(MODULE, '模拟柜原地合并三期 CONFIRMED 真实订舱柜', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
@@ -337,7 +325,8 @@ export async function run(context) {
const bookingReleased = context.apiSuccess(MODULE, '三期订舱携带已冻结运输快照并放舱', releaseResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse,
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
&& bookingReleased?.containers?.some?.((item) => id(item.containerBookingId) === containerBookingId
&& item.containerNo === containerNo)
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
@@ -352,7 +341,7 @@ export async function run(context) {
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
context.assert(MODULE, '真实柜合并放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
@@ -14,6 +14,7 @@ test('each remainder uses a simulation linked to a confirmed then released booki
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`/)
const releaseStart = source.indexOf('const releaseResponse')
@@ -39,18 +40,18 @@ test('release stops unless creation response and detail expose the same complete
assert.match(source, /if \(!transportFrozen\) return null/)
})
test('PDA opens only after a real container number and uses stable plan identity', () => {
test('PDA opens only after merging a registered real container and uses stable plan identity', () => {
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`")
const releaseIndex = source.indexOf("`${bookingPath}/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(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex
assert.ok(linkIndex >= 0 && releaseIndex > linkIndex && pushIndex > releaseIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /containerNo: realContainerNo/)
assert.match(source, /const transportStable = transported\?\.containerNo === realContainerNo/)
assert.match(source, /transported\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /const realContainerNo = requirement\?\.containerNo/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
assert.match(source, /计划确认前不生成 PDA 任务/)
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
@@ -92,8 +93,8 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/)
assert.match(source, /warehouseId: source\.warehouseId/)
assert.match(source, /containerType === '40HQ'/)
assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000/)
assert.match(source, /containerType === '40HC'/)
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
})
test('booking fixture never invents supplier names without formal IDs', () => {
@@ -148,7 +148,7 @@ async function loadOneFragment(context, state) {
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000,
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -164,12 +164,13 @@ async function loadOneFragment(context, state) {
const bookingConfirmed = bookingCreated
context.assert(MODULE, `${sourceOrderNo} 一次登记直接返回 CONFIRMED`, bookingCreate,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED'
&& bookingConfirmed?.containerAllocations?.length === 0,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) return null
&& 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 linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
@@ -213,29 +214,21 @@ async function loadOneFragment(context, state) {
isConflict(staleAssign) && hasTrace(staleAssign),
`HTTP=${staleAssign.http}, code=${codeOf(staleAssign)}, requestId=${staleAssign.requestId || 'missing'}, traceId=${staleAssign.traceId || 'missing'}`, OPTIONS)
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS)
const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, {
containerNo: realContainerNo, voyageNo: `V-${sequence}`,
version: Number(containerDetail?.version), reason: context.marker,
})
const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS)
const transportStable = transported?.containerNo === realContainerNo && id(transported?.id) === containerId
&& transported?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse,
const transportStable = linked?.containerNo === realContainerNo && id(linked?.id) === containerId
&& linked?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 合并后真实柜号落在稳定模拟柜身份`, linkResponse,
transportStable,
`containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}, cabinetNo=${transported?.cabinetNo || 'missing'}`, OPTIONS)
`containerBookingId=${containerId}, containerNo=${linked?.containerNo || 'missing'}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
if (!transportStable) return null
const bookingPath = `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`
let bookingDetailResponse = await context.request('GET', bookingPath)
let bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 柜次所属真实订舱`, bookingDetailResponse, '', OPTIONS)
const allocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
const allocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 订舱分配保持稳定柜次`, bookingDetailResponse,
id(bookingDetail?.oceanBooking?.id) === oceanBookingId && allocationMatches,
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`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)
@@ -262,7 +255,7 @@ async function loadOneFragment(context, state) {
bookingDetailResponse = await context.request('GET', bookingPath)
bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的 RELEASED 订舱`, bookingDetailResponse, '', OPTIONS)
}
const releasedAllocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
bookingDetail?.oceanBooking?.status === 'RELEASED' && releasedAllocationMatches,
@@ -42,7 +42,6 @@ async function request(context, method, path, body, headers = {}) {
function oceanDraft(context, suffix, master) {
return {
...oceanBookingDraft(context, suffix, master),
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -52,7 +51,6 @@ function oceanDraft(context, suffix, master) {
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
@@ -65,7 +63,7 @@ async function ensureOceanBooking(context, suffix, master) {
return detail?.oceanBooking ? {
id: existingId,
customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
allocatedContainerBookingIds: (detail.containers || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
@@ -78,7 +76,7 @@ async function ensureOceanBooking(context, suffix, master) {
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
return oceanBookingId ? {
id: oceanBookingId, customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
allocatedContainerBookingIds: (detail.containers || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
@@ -90,9 +90,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'无真实柜号推送预装成功并冻结柜次明细',
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'预装 EDITING 清单绑定真实柜号',
'已补号但未关联订舱仍无 PDA 任务',
'预装补号后关联三期 CONFIRMED 订舱',
'无号且未关联订舱仍无 PDA 任务',
'模拟柜原地合并三期 CONFIRMED 真实订舱柜',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
@@ -244,38 +243,36 @@ test('container loading arranges a simulation before linking a confirmed real bo
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
"const assignResponse = await request",
'无真实柜号推送预装计划成功',
'预装 EDITING 清单绑定本轮真实柜号',
"const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
])
assert.match(value, /channelCode, warehouseId, warehouseName/)
assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/)
assert.match(value, /warehouseId, warehouseName/)
const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\''))
assert.doesNotMatch(draft, /warehouseName\s*:/)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(draft, new RegExp(`${field}:`))
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /containerAllocations\.length === 0/)
assert.match(value, /expectedVersion: Number\(postPushPlan\.version\), operationReason: context\.marker/)
assert.match(value, /confirmedBooking\.containers\.length === 2/)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
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, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink,\s+responseEvidence\(emptyRelink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyFinalUnlink = await request/)
assert.match(value, /const emptyReleased = context\.apiSuccess\(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink,\s+responseEvidence\(emptyFinalUnlink\), OPTIONS\)\s+if \(!emptyReleased\) return\s+const beforeLinkItems/)
assert.match(value, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(value, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.match(value, /已消费 MERGED 真实柜拒绝再次关联/)
assertOrder(value, [
"const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,",
'if (!linkedContainer) return',
"const afterLinkPlanResponse = await context.request('GET'",
"const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS",
])
assert.equal(value.match(/responseEvidence\((?:emptyLink|emptyUnlink|emptyRelink|emptyFinalUnlink|linkResponse)\)/g)?.length, 5)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
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.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId='))
assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
@@ -360,8 +357,7 @@ test('container loading enforces channel isolation and stable PDA identity acros
'人工分配本轮候选货物',
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'预装 EDITING 清单绑定本轮真实柜号',
'已补号但未关联订舱不生成 PDA 任务',
'无号且未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
@@ -501,7 +497,7 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.doesNotMatch(serviceCenter, /链路承运商-/)
assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/)
const schedules = source('./phase3-port-schedules.mjs')
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/)
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
})
@@ -64,10 +64,10 @@ export async function run(context) {
return
}
if (linkContainer) {
const allocationMatches = oceanDetail?.containerAllocations?.some?.((item) =>
const allocationMatches = oceanDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
`containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches) return
}
const payload = {