199 lines
16 KiB
JavaScript
199 lines
16 KiB
JavaScript
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'
|
|
|
|
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') }
|
|
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') }
|
|
} 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 }) }
|
|
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, '未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜', 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)
|
|
|
|
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: 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?.containerAllocations?.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)
|
|
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?.containerAllocations?.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
|
|
}
|
|
|
|
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 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 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)
|
|
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?.containerRequirements?.length === 1
|
|
&& ['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)
|
|
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)
|
|
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 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 (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
|
|
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 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)
|
|
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)
|
|
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)}`)
|
|
}
|