test(firstleg): cover simulated container booking links
This commit is contained in:
@@ -45,7 +45,7 @@ export async function run(context) {
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-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-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-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-pda-convergence.mjs`、`phase3-evidence-gate.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-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` |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
|
||||
const containerDtos = read('../../firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java')
|
||||
const containerService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java')
|
||||
const oceanService = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java')
|
||||
const oceanMapper = read('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java')
|
||||
const firstlegOpenApi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
|
||||
const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
|
||||
const loadingFixture = read('phase3-container-loading.mjs')
|
||||
const fullEntry = read('../admin-test-env-acceptance.mjs')
|
||||
const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md')
|
||||
|
||||
test('operational ocean booking list excludes unconfirmed data', () => {
|
||||
assert.match(oceanService, /OPERATIONAL_LIST_STATUSES\s*=\s*Set\.of\([\s\S]*CONFIRMED[\s\S]*CHANGE_PENDING[\s\S]*RELEASED[\s\S]*CANCELLED/)
|
||||
assert.match(oceanMapper, /status IN \('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED'\)/)
|
||||
assert.match(oceanOpenApi, /enum: \[CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED\]/)
|
||||
assert.match(oceanOpenApi, /DRAFT, SUBMITTED and FAILED are excluded/)
|
||||
assert.doesNotMatch(oceanOpenApi.match(/name: status[^\n]*/)?.[0] || '', /DRAFT|SUBMITTED/)
|
||||
})
|
||||
|
||||
test('simulation creation uses server-owned identity and formal master data', () => {
|
||||
assert.match(controller, /@PostMapping[\s\S]*SimulationCreateRequest request[\s\S]*createSimulation/)
|
||||
assert.match(containerDtos, /SimulationCreateRequest\(@NotBlank[\s\S]*String channelCode[\s\S]*Long warehouseId[\s\S]*String containerType[\s\S]*BigDecimal capacityCbm[\s\S]*BigDecimal maxWeightKg/)
|
||||
assert.match(containerService, /requireDomesticTransitWarehouse\(request\.warehouseId\(\)\)/)
|
||||
assert.match(containerService, /activeVersionId\(\)[\s\S]*priceBooks\.channels\(versionId, warehouse\.id\(\)\)/)
|
||||
assert.match(containerService, /setCabinetNo\("CAB-" \+ value\.getId\(\)\)/)
|
||||
assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/)
|
||||
})
|
||||
|
||||
test('first link and unlink gates are intentionally asymmetric', () => {
|
||||
assert.match(controller, /@PostMapping\("\/\{id\}\/ocean-booking-link"\)/)
|
||||
assert.match(controller, /@DeleteMapping\("\/\{id\}\/ocean-booking-link"\)/)
|
||||
assert.match(containerService, /requireFirstLinkMutable\(container\)/)
|
||||
assert.match(containerService, /WAIT_LOADING && value != SmartLoadingStatus\.EDITING/)
|
||||
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 模拟柜/)
|
||||
})
|
||||
|
||||
test('real booking association locks scope capacity and stable internal identity', () => {
|
||||
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.doesNotMatch(containerService.match(/linkOceanBooking[\s\S]*?return required\(id\);/)?.[0] || '', /container\.setId\(|container\.setCabinetNo\(/)
|
||||
})
|
||||
|
||||
test('HTTP fixture follows simulation arrange link PDA sequence without auto-container fallback', () => {
|
||||
const ordered = [
|
||||
'新增内部模拟柜',
|
||||
'人工分配本轮候选货物',
|
||||
'首次关联真实已确认订舱柜',
|
||||
'查询本轮柜次 PDA 拣货任务',
|
||||
].map((label) => loadingFixture.indexOf(label))
|
||||
assert.ok(ordered.every((value) => value >= 0), `missing sequence label: ${ordered.join(',')}`)
|
||||
assert.ok(ordered.every((value, index) => index === 0 || value > ordered[index - 1]), `invalid sequence: ${ordered.join(',')}`)
|
||||
assert.match(loadingFixture, /ocean-booking-link/)
|
||||
assert.match(loadingFixture, /关联前后内部柜次、计划和排货明细身份稳定/)
|
||||
assert.match(loadingFixture, /同一真实订舱箱型容量拒绝重复关联/)
|
||||
assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/)
|
||||
assert.match(loadingFixture, /空模拟柜解绑后允许重新关联/)
|
||||
assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/)
|
||||
})
|
||||
|
||||
test('default full acceptance preserves the linked simulation chain before downstream completion', () => {
|
||||
const loading = fullEntry.indexOf("'phase3-container-loading'")
|
||||
const pda = fullEntry.indexOf("'phase3-pda-convergence'")
|
||||
const finalize = fullEntry.indexOf("'phase3-loading-finalize'")
|
||||
const outbound = fullEntry.indexOf("'phase3-outbound-dispatch'")
|
||||
assert.ok(loading >= 0 && pda > loading && finalize > pda && outbound > finalize)
|
||||
assert.match(matrix, /内部模拟柜关联真实订舱增量/)
|
||||
assert.doesNotMatch(matrix, /CONFIRMED` 自动建柜[\s\S]*既有冻结/)
|
||||
})
|
||||
@@ -104,13 +104,20 @@ async function createConfirmedContainer(context, source, label) {
|
||||
warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
|
||||
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`,
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: `${context.marker}-${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 }],
|
||||
}
|
||||
const simulationResponse = await context.request('POST', CONTAINERS, {
|
||||
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 68, maxWeightKg: 28000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
if (!containerId) throw new Error(`${label} 未生成内部模拟柜`)
|
||||
const createdResponse = await post(context, '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
|
||||
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
|
||||
@@ -121,8 +128,22 @@ async function createConfirmedContainer(context, source, label) {
|
||||
const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||
{ expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`)
|
||||
const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS)
|
||||
const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`)
|
||||
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
if (!requirement?.id) 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),
|
||||
})
|
||||
const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS)
|
||||
if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) {
|
||||
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
|
||||
}
|
||||
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
|
||||
expectedVersion: Number(confirmed?.oceanBooking?.version), billOfLadingNo: `BL-${value}`.slice(0, 128),
|
||||
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
|
||||
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
|
||||
reason: context.marker,
|
||||
}, `${label}-booking-release`)
|
||||
context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS)
|
||||
return containerId
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse
|
||||
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
|
||||
})
|
||||
|
||||
test('container confirmation acceptance locks the selected channel onto the generated container', () => {
|
||||
test('explicit real booking association locks the selected channel onto the simulation', () => {
|
||||
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
|
||||
assert.match(fixture, /containerAllocations\?\.\[0\]/)
|
||||
assert.match(fixture, /ocean-booking-link/)
|
||||
assert.match(fixture, /CONFIRMED 只产生真实订舱事实且尚未占用模拟柜/)
|
||||
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
|
||||
assert.match(fixture, /selectPlatformChannelIsolationPair/)
|
||||
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
|
||||
|
||||
@@ -84,7 +84,7 @@ test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer
|
||||
assert.match(fixture, /serviceTerm: 'CY-CY', freightTerm: 'NINGBO'/)
|
||||
assert.doesNotMatch(fixture, /serviceTerm: 'CY\/CY'|freightTerm: 'PREPAID'/)
|
||||
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
|
||||
assert.match(bookingModule, /未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次/)
|
||||
assert.match(bookingModule, /未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜/)
|
||||
assert.match(bookingModule, /按真实订舱号查询未指定客户订舱/)
|
||||
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
|
||||
assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
|
||||
|
||||
@@ -25,6 +25,7 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
|
||||
const createdResponse = await request(context, 'POST', BASE, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': key })
|
||||
const created = data(createdResponse)
|
||||
const bookingId = id(created?.oceanBooking?.id)
|
||||
const createdBookingNo = id(created?.oceanBooking?.bookingNo)
|
||||
context.apiSuccess(MODULE, '创建未指定客户订舱', createdResponse,
|
||||
`marker=${context.marker}, customerFields=${'customerId' in draft || 'customerName' in draft ? 'present' : 'omitted'}`, OPTIONS)
|
||||
context.assert(MODULE, '未指定客户订舱不伪造客户快照', createdResponse,
|
||||
@@ -32,6 +33,12 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
|
||||
`bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS)
|
||||
if (!bookingId) return null
|
||||
|
||||
const hiddenResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(createdBookingNo)}`)
|
||||
const hiddenPage = context.apiSuccess(MODULE, '未确认订舱不进入正式订舱列表', hiddenResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'DRAFT 未指定客户订舱仅可按详情维护', hiddenResponse,
|
||||
!hiddenPage?.records?.some?.((item) => id(item.id) === bookingId),
|
||||
`bookingId=${bookingId}, records=${hiddenPage?.records?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const path = `${BASE}/${bookingId}`
|
||||
const submitResponse = await request(context, 'POST', `${path}/submit`, {
|
||||
expectedVersion: Number(created.oceanBooking.version), reason: context.marker,
|
||||
@@ -40,16 +47,15 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
|
||||
const confirmResponse = await request(context, 'POST', `${path}/confirm`, {
|
||||
expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-ocean-unassigned-confirm`.slice(0, 128) })
|
||||
context.apiSuccess(MODULE, '确认未指定客户订舱并生成柜次', confirmResponse, '', OPTIONS)
|
||||
context.apiSuccess(MODULE, '确认未指定客户真实订舱', confirmResponse, '', OPTIONS)
|
||||
|
||||
const detailResponse = await request(context, 'GET', path)
|
||||
const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS)
|
||||
const containerBookingId = id(detail?.containerAllocations?.[0]?.containerBookingId)
|
||||
const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo)
|
||||
context.assert(MODULE, '未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次', detailResponse,
|
||||
context.assert(MODULE, '未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜', detailResponse,
|
||||
detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null
|
||||
&& detail?.oceanBooking?.customerName == null && Boolean(containerBookingId) && Boolean(bookingNo),
|
||||
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS)
|
||||
&& 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)
|
||||
|
||||
const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
|
||||
const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS)
|
||||
@@ -57,7 +63,7 @@ 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,
|
||||
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId: null,
|
||||
finalStatus: detail?.oceanBooking?.status || null }
|
||||
context.createdResources.oceanBookingUnassignedCustomer = resource
|
||||
return resource
|
||||
@@ -119,8 +125,9 @@ 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 statuses = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CANCELLED']
|
||||
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)}`) }
|
||||
|
||||
await createConfirmedUnassignedBooking(context, suffix, master)
|
||||
|
||||
@@ -154,7 +161,7 @@ export async function run(context) {
|
||||
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
||||
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
|
||||
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
|
||||
let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
const allocatedContainerBookingId = ''
|
||||
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
||||
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
|
||||
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
|
||||
@@ -163,9 +170,9 @@ export async function run(context) {
|
||||
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,
|
||||
Boolean(allocatedContainerBookingId) && Number.isInteger(version),
|
||||
`containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS)
|
||||
context.assert(MODULE, '独立订舱生命周期不占用本轮已装货柜次', changed,
|
||||
changeConfirmed?.containerAllocations?.length === 0 && Number.isInteger(version),
|
||||
`allocations=${changeConfirmed?.containerAllocations?.length ?? 'n/a'}, version=${version}`, OPTIONS)
|
||||
}
|
||||
const released = await request(context, 'POST', `${path}/release`, {
|
||||
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
||||
@@ -177,9 +184,9 @@ export async function run(context) {
|
||||
version = Number(releasedDetail?.oceanBooking?.version)
|
||||
const final = await request(context, 'GET', path); const fd = data(final)
|
||||
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
|
||||
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
|
||||
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
|
||||
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName)
|
||||
&& fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId),
|
||||
&& fd?.containerAllocations?.length === 0,
|
||||
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
let downstreamBooking = {
|
||||
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
|
||||
|
||||
@@ -45,7 +45,7 @@ function oceanDraft(context, suffix, master) {
|
||||
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
|
||||
@@ -108,6 +108,7 @@ 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 bookingDraft = {
|
||||
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
||||
channelCode, warehouseId, warehouseName,
|
||||
@@ -115,31 +116,27 @@ export async function run(context) {
|
||||
bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`,
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
|
||||
serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: context.marker,
|
||||
serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }],
|
||||
containerRequirements: [primaryRequirement,
|
||||
{ containerType: '20GP', containerKind: 'FCL', capacityCbm: 28, maxWeightKg: 22000, quantity: 1 }],
|
||||
}
|
||||
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
|
||||
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
||||
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
||||
const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS)
|
||||
const bookingId = id(bookingCreated?.oceanBooking?.id)
|
||||
if (!bookingId) return
|
||||
const bookingVersion = Number(bookingCreated.oceanBooking.version)
|
||||
const submit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`,
|
||||
{ expectedVersion: bookingVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-submit` })
|
||||
const submitted = context.apiSuccess(MODULE, '提交隔离订舱', submit, '', OPTIONS)
|
||||
const confirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||
{ expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-confirm` })
|
||||
const confirmedBooking = context.apiSuccess(MODULE, '确认订舱并自动生成真实柜次', confirm, '', OPTIONS)
|
||||
const allocation = confirmedBooking?.containerAllocations?.[0]
|
||||
const containerId = id(allocation?.containerBookingId)
|
||||
context.assert(MODULE, '确认响应包含自动生成的真实柜次', confirm,
|
||||
Boolean(containerId) && id(confirmedBooking?.oceanBooking?.id) === bookingId,
|
||||
`bookingId=${bookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS)
|
||||
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode, warehouseId, containerType: primaryRequirement.containerType,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse,
|
||||
`warehouseId=${warehouseId}, channelCode=${channelCode}`, OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse,
|
||||
Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
|
||||
&& simulation?.oceanBookingId == null && simulation?.channelCode === channelCode
|
||||
&& id(simulation?.warehouseId) === warehouseId,
|
||||
`containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS)
|
||||
if (!containerId) return
|
||||
|
||||
const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
@@ -160,22 +157,12 @@ export async function run(context) {
|
||||
`${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS)
|
||||
} else {
|
||||
const foreignChannel = channelIsolation.pair.foreign
|
||||
const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
||||
expectedVersion: 0,
|
||||
data: { ...bookingDraft, channelCode: foreignChannel.channelCode, remark: `${context.marker}-other-channel` },
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) })
|
||||
const foreignBookingCreated = context.apiSuccess(MODULE, '创建真实异渠道隔离订舱', foreignBookingCreate, '', OPTIONS)
|
||||
const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id)
|
||||
if (!foreignBookingId) return
|
||||
const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, {
|
||||
expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) })
|
||||
const foreignSubmitted = context.apiSuccess(MODULE, '提交真实异渠道隔离订舱', foreignSubmit, '', OPTIONS)
|
||||
const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, {
|
||||
expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) })
|
||||
const foreignConfirmed = context.apiSuccess(MODULE, '确认真实异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS)
|
||||
const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
const foreignContainerResponse = await request(context, 'POST', CONTAINERS, {
|
||||
channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType,
|
||||
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
|
||||
})
|
||||
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
|
||||
const foreignContainerId = id(foreignContainer?.id)
|
||||
if (!foreignContainerId) return
|
||||
const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, {
|
||||
bookingId: foreignContainerId, remark: `${context.marker}-other-channel`,
|
||||
@@ -240,6 +227,108 @@ export async function run(context) {
|
||||
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
|
||||
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
|
||||
|
||||
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
||||
const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的真实订舱草稿', bookingCreate, '', OPTIONS)
|
||||
const bookingId = id(bookingCreated?.oceanBooking?.id)
|
||||
const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo)
|
||||
if (!bookingId) return
|
||||
const hiddenDraftResponse = await context.request('GET',
|
||||
`/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
|
||||
const hiddenDraftPage = context.apiSuccess(MODULE, '正式订舱列表查询不含草稿', hiddenDraftResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '真实订舱确认前不进入正式列表', hiddenDraftResponse,
|
||||
!records(hiddenDraftPage).some((item) => id(item.id) === bookingId),
|
||||
`bookingId=${bookingId}, records=${records(hiddenDraftPage).length}`, OPTIONS)
|
||||
const draftFilterResponse = await request(context, 'GET', '/api/platform/v1/ocean-bookings?page=1&size=5&status=DRAFT')
|
||||
context.addStep(MODULE, '正式订舱列表拒绝 DRAFT 状态筛选', draftFilterResponse,
|
||||
failure(draftFilterResponse), `HTTP=${draftFilterResponse.http}, code=${codeOf(draftFilterResponse)}`, OPTIONS)
|
||||
|
||||
const submit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`,
|
||||
{ expectedVersion: Number(bookingCreated.oceanBooking.version), reason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-loading-booking-submit` })
|
||||
const submitted = context.apiSuccess(MODULE, '提交隔离订舱', submit, '', OPTIONS)
|
||||
const submittedFilterResponse = await request(context, 'GET', '/api/platform/v1/ocean-bookings?page=1&size=5&status=SUBMITTED')
|
||||
context.addStep(MODULE, '正式订舱列表拒绝 SUBMITTED 状态筛选', submittedFilterResponse,
|
||||
failure(submittedFilterResponse), `HTTP=${submittedFilterResponse.http}, code=${codeOf(submittedFilterResponse)}`, OPTIONS)
|
||||
const confirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||
{ expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-loading-booking-confirm` })
|
||||
const confirmedBooking = context.apiSuccess(MODULE, '确认真实订舱但不自动创建内部柜', confirm, '', OPTIONS)
|
||||
context.assert(MODULE, 'CONFIRMED 只产生真实订舱事实且尚未占用模拟柜', confirm,
|
||||
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
|
||||
&& Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0,
|
||||
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
const confirmedListResponse = await context.request('GET',
|
||||
`/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
|
||||
const confirmedList = context.apiSuccess(MODULE, '按真实订舱号查询正式列表', confirmedListResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'CONFIRMED 后真实订舱进入正式列表', confirmedListResponse,
|
||||
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 wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
||||
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(containerDetail.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, '', OPTIONS)
|
||||
const emptyUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
|
||||
expectedVersion: Number(emptySimulation.version), reason: context.marker,
|
||||
})
|
||||
emptySimulation = context.apiSuccess(MODULE, '空模拟柜允许解除关联', emptyUnlink, '', OPTIONS)
|
||||
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, '', OPTIONS)
|
||||
const emptyFinalUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
|
||||
expectedVersion: Number(emptySimulation.version), reason: `${context.marker}-release-capacity`,
|
||||
})
|
||||
context.apiSuccess(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink, '', OPTIONS)
|
||||
|
||||
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(containerDetail.version),
|
||||
})
|
||||
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse, '', OPTIONS)
|
||||
const afterLinkPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
||||
const afterLinkPlan = context.apiSuccess(MODULE, '关联后回查原装柜计划与明细', afterLinkPlanResponse, '', OPTIONS)
|
||||
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
|
||||
&& beforeLinkItems === afterLinkItems,
|
||||
`containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || '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),
|
||||
})
|
||||
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 pushKey = `${context.marker}-container-push`.slice(0, 128)
|
||||
const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function run(context) {
|
||||
channelCode: initial?.summary?.channelCode,
|
||||
shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`,
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
|
||||
@@ -131,6 +131,17 @@ export async function run(context) {
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
|
||||
maxWeightKg: 100000, quantity: 1 }],
|
||||
}
|
||||
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
|
||||
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HQ',
|
||||
capacityCbm: 100, maxWeightKg: 100000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
|
||||
const containerBookingId = id(simulation?.id)
|
||||
context.assert(MODULE, '三期模拟柜不要求真实柜号或订舱', simulationResponse,
|
||||
Boolean(containerBookingId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
|
||||
&& simulation?.oceanBookingId == null,
|
||||
`containerBookingId=${containerBookingId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}`, OPTIONS)
|
||||
if (!containerBookingId) return
|
||||
const bookingCreateResponse = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: bookingDraft },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-booking-create`.slice(0, 128) })
|
||||
@@ -144,9 +155,21 @@ export async function run(context) {
|
||||
const bookingConfirmResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||
{ expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-p3-booking-confirm`.slice(0, 128) })
|
||||
const bookingConfirmed = context.apiSuccess(MODULE, '确认订舱并自动生成三期柜次', bookingConfirmResponse, '', OPTIONS)
|
||||
const containerBookingId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (!containerBookingId) return
|
||||
const bookingConfirmed = context.apiSuccess(MODULE, '确认真实订舱且不自动生成三期内部柜', bookingConfirmResponse, '', OPTIONS)
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
context.assert(MODULE, '订舱确认仅冻结真实订舱及箱型需求', bookingConfirmResponse,
|
||||
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id)
|
||||
&& bookingConfirmed?.containerAllocations?.length === 0,
|
||||
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, requirementId=${id(requirement?.id) || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
if (!requirement?.id) return
|
||||
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
|
||||
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
|
||||
})
|
||||
const linked = context.apiSuccess(MODULE, '关联三期模拟柜与真实订舱', linkResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '三期订舱关联保持内部柜次身份', linkResponse,
|
||||
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
|
||||
&& id(linked?.oceanBookingId) === bookingId,
|
||||
`containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
|
||||
|
||||
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
|
||||
|
||||
@@ -4,14 +4,14 @@ import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('each remainder follows confirmed booking before scoped smart-loading candidates', () => {
|
||||
test('each remainder uses a simulation explicitly linked to a confirmed booking', () => {
|
||||
assert.match(source, /POST', CONTAINERS/)
|
||||
assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/)
|
||||
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/submit/)
|
||||
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/confirm/)
|
||||
assert.match(source, /containerAllocations\?\.\[0\]\?\.containerBookingId/)
|
||||
assert.match(source, /ocean-booking-link/)
|
||||
assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
|
||||
assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/)
|
||||
assert.doesNotMatch(source, /request\(context, 'POST', CONTAINERS/)
|
||||
assert.doesNotMatch(source, /candidates\?warehouseId=/)
|
||||
})
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ function bookingPayload(marker, sequence, source) {
|
||||
shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
|
||||
bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: marker,
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }],
|
||||
@@ -137,6 +137,13 @@ function bookingPayload(marker, sequence, source) {
|
||||
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: 100, maxWeightKg: 100000,
|
||||
})
|
||||
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||
const containerId = id(simulation?.id)
|
||||
if (!containerId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
||||
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
||||
expectedVersion: 0, data: bookingPayload(context.marker, sequence, state),
|
||||
}, { 'Idempotency-Key': `${operationBase}-booking-create` })
|
||||
@@ -152,13 +159,16 @@ async function loadOneFragment(context, state) {
|
||||
const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, {
|
||||
expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${operationBase}-booking-confirm` })
|
||||
const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的订舱并自动分配柜次`, bookingConfirm, '', OPTIONS)
|
||||
const containerId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
context.assert(MODULE, `${sourceOrderNo} 订舱确认后取得稳定柜次`, bookingConfirm,
|
||||
isPositiveId(containerId) && id(bookingConfirmed?.oceanBooking?.id) === oceanBookingId,
|
||||
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS)
|
||||
if (!containerId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
||||
const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的真实订舱`, bookingConfirm, '', OPTIONS)
|
||||
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
|
||||
if (!requirement?.id) return null
|
||||
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
|
||||
oceanBookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
|
||||
})
|
||||
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
|
||||
isPositiveId(containerId) && id(linked?.id) === containerId && linked?.cabinetNo === simulation.cabinetNo,
|
||||
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerId}`, OPTIONS)
|
||||
|
||||
const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const candidatesResponse = await context.request('GET', candidatesPath)
|
||||
|
||||
@@ -45,7 +45,7 @@ function oceanDraft(context, suffix, master) {
|
||||
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
|
||||
@@ -130,30 +130,33 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
||||
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
|
||||
})
|
||||
|
||||
test('container loading creates and confirms booking before scoped candidates', () => {
|
||||
test('container loading arranges a simulation before linking a confirmed real booking', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assert.match(value, /candidates\.find\(\(candidate\) => candidate\.marker === context\.marker\)/)
|
||||
assertOrder(value, [
|
||||
"const bookingCreate = await request",
|
||||
"const submit = await request",
|
||||
"const confirm = await request",
|
||||
"const simulationResponse = await request(context, 'POST', CONTAINERS",
|
||||
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
|
||||
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
|
||||
"const assignResponse = await request",
|
||||
"const bookingCreate = await request",
|
||||
"const submit = await request",
|
||||
"const confirm = 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, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/)
|
||||
assert.match(value, /containerAllocations\?\.\[0\]/)
|
||||
assert.match(value, /containerAllocations\.length === 0/)
|
||||
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("'PATCH', `${CONTAINERS}/${containerId}/transport"))
|
||||
assert.match(value, /在放舱前绑定本轮真实箱号/)
|
||||
assert.match(value, /订舱放舱并开放现场 PDA 任务/)
|
||||
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
|
||||
assert.match(value, /POST', CONTAINERS/)
|
||||
})
|
||||
|
||||
test('container loading selects the current marker instead of a stale CAB fixture', () => {
|
||||
@@ -220,9 +223,11 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
|
||||
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assertOrder(value, [
|
||||
'确认订舱并自动生成真实柜次',
|
||||
'创建真实异渠道隔离订舱',
|
||||
'新增内部模拟柜',
|
||||
'新增真实异渠道模拟柜',
|
||||
'两个真实有效渠道之间拒绝跨渠道装柜',
|
||||
'人工分配本轮候选货物',
|
||||
'首次关联真实已确认订舱柜',
|
||||
'无真实柜号时计划可排柜但 PDA 任务不开放',
|
||||
'在放舱前绑定本轮真实箱号',
|
||||
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
|
||||
|
||||
@@ -55,7 +55,7 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo
|
||||
assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/)
|
||||
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
|
||||
assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/)
|
||||
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerBookingId/)
|
||||
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerAllocations\?\.length === 0/)
|
||||
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
|
||||
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
|
||||
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
|
||||
|
||||
Reference in New Issue
Block a user