const MODULE = 'ocean-bookings' const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/ocean-bookings' import { oceanBookingDraft, resolveOceanBookingAgent, 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() !== '') } function minimalPdfBytes(label) { const safe = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64) return new TextEncoder().encode(`%PDF-1.4\n% ${safe}\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n`) } 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 agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) step(context, '使用 ACTIVE BOOKING 订舱代理事实源', null, Boolean(agent?.id && agent?.name), `supplierId=${id(agent?.id) || 'missing'}`) if (!agent?.id || !agent?.name) return const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) step(context, '使用 ACTIVE CARRIER 船公司事实源', null, 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 form = new FormData() form.append('expectedVersion', String(completeVersion)) form.append('documentType', 'BILL_OF_LADING') form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) const upload = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, form, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-file`.slice(0, 128) }) const uploaded = context.apiSuccess(MODULE, '上传放舱所需有效提单文件', upload, '', OPTIONS) if (!uploaded?.bookingVersion) return const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`, { expectedVersion: Number(uploaded.bookingVersion), 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 })) } }