This commit is contained in:
2026-08-18 16:03:42 +08:00
parent 12e9f3746e
commit 82ee80226e
15 changed files with 196 additions and 319 deletions
@@ -55,9 +55,8 @@ export async function resolveOceanBookingMaster(context, module) {
export function oceanBookingDraft(context, suffix, master, labels = {}) {
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,
const containerRequirements = labels.containerRequirements || [{
containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000,
}]
return {
...writableMaster,
@@ -75,7 +74,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containers,
containerRequirements,
}
}
+84 -251
View File
@@ -1,269 +1,102 @@
const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
function missing(r) { return r?.http === 404 || r?.http === 405 || r?.http === 503 }
function failure(r) { return [400, 401, 403, 404, 409, 503].includes(r?.http) || (r?.http === 200 && codeOf(r) !== '200') }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function payload(response) { return codeOf(response) === '200' ? response.body.data : null }
function failed(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') }
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) })
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
const response = await fetch(`${context.options.gateway}${path}`, {
method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
})
const raw = await response.text(); let parsed = null
try { parsed = raw ? JSON.parse(raw) : null } catch { /* retain bounded raw evidence */ }
return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null,
requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') }
} 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, bookingAgent) {
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,
bookingAgentId: bookingAgent.supplierId,
bookingAgentName: bookingAgent.name,
}) },
{ 'Idempotency-Key': key })
function step(context, name, response, pass, evidence = '') { context.addStep(MODULE, name, response, pass, evidence, OPTIONS) }
function fullDraft(context, suffix, master, labels) {
return oceanBookingDraft(context, suffix, master, {
...labels,
containerRequirements: labels.containerRequirements || [
{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
],
})
}
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) {
const draft = oceanBookingDraftWithoutCustomer(context, `${suffix}U`, master)
const key = `${context.marker}-ocean-unassigned-create`.slice(0, 128)
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,
Boolean(bookingId) && created?.oceanBooking?.customerId == null && created?.oceanBooking?.customerName == null,
`bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS)
if (!bookingId) return null
const path = `${BASE}/${bookingId}`
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, '未指定客户订舱保持空客户且原子生成一条真实柜', detailResponse,
detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null
&& 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)
context.assert(MODULE, '未指定客户订舱可按真实订舱号从正式列表查询', listResponse,
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: id(detail?.containers?.[0]?.containerBookingId) || null,
finalStatus: detail?.oceanBooking?.status || null }
context.createdResources.oceanBookingUnassignedCustomer = resource
return resource
}
async function releaseLoadingBooking(context, containerFlow, suffix) {
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null
const flowPath = `${BASE}/${flowOceanBookingId}`
let flowResponse = await request(context, 'GET', flowPath)
let flow = data(flowResponse)
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
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)}, 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]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
context.assert(MODULE, '实际装货柜次订舱回读完整冻结运输快照', flowResponse, frozenTransportValid,
`oceanBookingId=${flowOceanBookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportValid) return null
const flowRelease = await request(context, 'POST', `${flowPath}/release`, {
expectedVersion: Number(flow.oceanBooking.version), ...frozenTransport,
reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) })
context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS)
flowResponse = await request(context, 'GET', flowPath)
flow = data(flowResponse)
}
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
flow?.oceanBooking?.status === 'RELEASED'
&& 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,
containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId),
finalStatus: 'RELEASED',
} : null
function transportSnapshot(value) {
return ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
.reduce((out, key) => { out[key] = value?.[key]; return out }, {})
}
function hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') }
export async function run(context) {
const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const existing = context.createdResources?.oceanBooking
if (existing?.marker === context.marker && existing?.finalStatus === 'RELEASED') {
if (!id(containerFlow?.containerBookingId)
|| id(existing.containerBookingId) === id(containerFlow.containerBookingId)) return
const reconciled = await releaseLoadingBooking(context, containerFlow, suffix)
if (reconciled) context.createdResources.oceanBooking = reconciled
return
}
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const bookingAgentResponse = await request(context, 'GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const bookingAgentPage = context.apiSuccess(MODULE, '查询启用的订舱供应商', bookingAgentResponse, '', OPTIONS)
const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse,
Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'),
`supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS)
if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return
const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name }
const list = await request(context, 'GET', `${BASE}?page=1&size=5`)
if (missing(list) || list.http === 0) {
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`)
return
}
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)}`) }
await createConfirmedUnassignedBooking(context, suffix, master)
const key = `${context.marker}-ocean-create`
const createResponse = await createBooking(context, suffix, key, master, bookingAgentSnapshot)
const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse,
Boolean(bookingId) && detail?.oceanBooking?.status === 'CONFIRMED'
&& 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'}, 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)
context.assert(MODULE, '订舱持久化启用订舱供应商快照', createResponse,
id(detail?.oceanBooking?.bookingAgentId) === bookingAgentSnapshot.supplierId
&& detail?.oceanBooking?.bookingAgentName === bookingAgentSnapshot.name,
`expected=${bookingAgentSnapshot.supplierId}/${bookingAgentSnapshot.name}, actual=${id(detail?.oceanBooking?.bookingAgentId) || 'missing'}/${detail?.oceanBooking?.bookingAgentName || '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)
const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING'
const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const agentPage = payload(agentResponse)
const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name),
`supplierId=${id(agent?.id) || 'missing'}, scope=${agent?.scope || 'not-exposed'}`)
if (!agent?.id || !agent?.name) return
const data = fullDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name })
const create = await request(context, 'POST', BASE, { expectedVersion: 0, data },
{ 'Idempotency-Key': `${context.marker}-ocean-draft-create`.slice(0, 128) })
const created = payload(create); const bookingId = id(created?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建订舱 DRAFT(不生成真实柜号/货柜分配)', create, '', OPTIONS)
context.assert(MODULE, '订舱创建返回 DRAFT 且只持久化柜型需求', create,
Boolean(bookingId) && created?.oceanBooking?.status === 'DRAFT'
&& Array.isArray(created?.containerRequirements) && created.containerRequirements.length === 2
&& (!created.containers || created.containers.length === 0),
`bookingId=${bookingId || 'missing'}, status=${created?.oceanBooking?.status || 'missing'}, requirements=${created?.containerRequirements?.length ?? 'n/a'}, containers=${created?.containers?.length ?? 0}`, 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, bookingAgentSnapshot)
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 }
const changed = await request(context, 'POST', `${path}/change-request`, {
expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`,
estimatedSailingAt: detail.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: detail.oceanBooking.estimatedArrivalAt,
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')
const releaseSnapshotResponse = await request(context, 'GET', path)
const releaseSnapshot = data(releaseSnapshotResponse)
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field]))
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshotResponse, frozenTransportMatches,
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportMatches) return
const allocatedContainerBookingId = ''
if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, 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,
}, { 'Idempotency-Key': `${context.marker}-ocean-release` })
const releasedDetail = data(released)
context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS)
version = Number(releasedDetail?.oceanBooking?.version)
const final = await request(context, 'GET', path); const fd = data(final)
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
const bookingFieldGaps = oceanBookingFieldGaps(fd?.oceanBooking)
context.assert(MODULE, '订舱列表所需客户、船公司、代理、航次与关键时间字段来源完整', final,
bookingFieldGaps.length === 0, `missing=${bookingFieldGaps.join(',') || 'none'}`, OPTIONS)
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName)
&& 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,
loadingPlanId: null,
finalStatus: fd?.oceanBooking?.status || null,
}
const releasedLoadingBooking = await releaseLoadingBooking(context, containerFlow, suffix)
if (releasedLoadingBooking) downstreamBooking = releasedLoadingBooking
context.createdResources.oceanBooking = downstreamBooking
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`)
const get = await request(context, 'GET', `${BASE}/${bookingId}`); const detail = payload(get)
step(context, '回读 DRAFT 真实持久化快照', get, detail?.oceanBooking?.status === 'DRAFT', `bookingId=${bookingId}`)
const draftVersion = Number(detail?.oceanBooking?.version)
const partial = { ...data, vesselName: null, voyageNo: null, estimatedSailingAt: null, estimatedArrivalAt: null }
const edit = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: draftVersion, data: partial },
{ 'Idempotency-Key': `${context.marker}-ocean-draft-edit`.slice(0, 128) })
step(context, 'DRAFT 可部分保存/编辑', edit, !failed(edit), `bookingId=${bookingId}, HTTP=${edit.http}, code=${codeOf(edit)}`)
const refreshed = payload(edit) || detail; const releaseVersion = Number(refreshed?.oceanBooking?.version ?? draftVersion)
const release = await request(context, 'POST', `${BASE}/${bookingId}/release`, { expectedVersion: releaseVersion, reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-ocean-release-incomplete`.slice(0, 128) })
step(context, '不完整 DRAFT 放舱明确拒绝且无状态漂移', release, failed(release),
`bookingId=${bookingId}, HTTP=${release.http}, code=${codeOf(release)}`)
const complete = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releaseVersion, data },
{ 'Idempotency-Key': `${context.marker}-ocean-draft-complete`.slice(0, 128) })
const completedDraft = payload(complete); const completeVersion = Number(completedDraft?.oceanBooking?.version ?? releaseVersion)
step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`)
if (failed(complete)) return
const frozen = transportSnapshot(completedDraft?.oceanBooking || data)
const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`,
{ expectedVersion: completeVersion, reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) })
const released = payload(finalRelease)
step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease,
!failed(finalRelease) && released?.oceanBooking?.status === 'RELEASED' && hasTransport(released.oceanBooking)
&& Object.entries(frozen).every(([key, value]) => String(released.oceanBooking[key]) === String(value)),
`bookingId=${bookingId}, status=${released?.oceanBooking?.status || 'missing'}`)
if (failed(finalRelease)) return
const releasedVersion = Number(released?.oceanBooking?.version)
const immutable = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releasedVersion, data },
{ 'Idempotency-Key': `${context.marker}-ocean-released-edit`.slice(0, 128) })
step(context, 'RELEASED 编辑拒绝且零写入', immutable, failed(immutable), `HTTP=${immutable.http}, code=${codeOf(immutable)}`)
const relist = await request(context, 'GET', `${BASE}/${bookingId}`); const relistData = payload(relist)
step(context, 'RELEASED 回查运输快照未漂移且没有预生成货柜', relist,
relistData?.oceanBooking?.status === 'RELEASED' && (!relistData?.containers || relistData.containers.length === 0)
&& Object.entries(frozen).every(([key, value]) => String(relistData.oceanBooking[key]) === String(value)), `bookingId=${bookingId}`)
const gaps = oceanBookingFieldGaps(relistData?.oceanBooking)
step(context, '订舱正式列表字段来源完整', relist, gaps.length === 0, `missing=${gaps.join(',') || 'none'}`)
context.createdResources.oceanBooking = { id: bookingId, marker: context.marker,
customerId: id(relistData?.oceanBooking?.customerId), bookingNo: id(relistData?.oceanBooking?.bookingNo),
finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null,
requirements: (relistData?.containerRequirements || []).map((value) => ({ requirementId: id(value.id), ...value })) }
}