126 lines
9.9 KiB
JavaScript
126 lines
9.9 KiB
JavaScript
const MODULE = 'ocean-bookings'
|
|
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
|
|
const BASE = '/api/platform/v1/ocean-bookings'
|
|
import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
|
|
import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
|
|
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
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 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 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 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 master = await resolveOceanBookingMaster(context, MODULE)
|
|
if (!master) return
|
|
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&carrierCode=MATSON&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 carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
|
step(context, '使用 ACTIVE CARRIER 船公司事实源', agentResponse,
|
|
Boolean(carrier?.id && carrier?.carrierCode === 'MATSON'), `partnerId=${id(carrier?.id) || 'missing'}`)
|
|
if (!carrier?.id) return
|
|
const data = fullDraft(context, suffix, master, {
|
|
bookingAgentId: id(agent.id), bookingAgentName: agent.name,
|
|
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
|
})
|
|
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
|
|
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 documentForm = new FormData()
|
|
documentForm.append('expectedVersion', String(completeVersion))
|
|
documentForm.append('documentType', 'BOOKING_CONFIRMATION')
|
|
documentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }),
|
|
`booking-confirmation-${suffix}.txt`)
|
|
const documentResponse = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`,
|
|
documentForm, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-document`.slice(0, 128) })
|
|
const document = context.apiSuccess(MODULE, '放舱前上传当前有效订舱确认文件', documentResponse, '', OPTIONS)
|
|
const documentVersion = Number(document?.bookingVersion)
|
|
context.assert(MODULE, '订舱确认文件归属当前订舱且递增版本', documentResponse,
|
|
Boolean(document?.document?.id) && id(document?.document?.oceanBookingId) === bookingId
|
|
&& document?.document?.documentType === 'BOOKING_CONFIRMATION' && document?.document?.valid === true
|
|
&& Number.isInteger(documentVersion) && documentVersion > completeVersion,
|
|
`documentId=${id(document?.document?.id) || 'missing'}, bookingVersion=${document?.bookingVersion ?? 'missing'}`, OPTIONS)
|
|
if (!Number.isInteger(documentVersion) || documentVersion <= completeVersion) return
|
|
const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`,
|
|
{ expectedVersion: documentVersion, 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 })) }
|
|
}
|