Files
baiyue-firstleg/scripts/admin-test-env-modules/ocean-bookings.mjs
T

103 lines
8.2 KiB
JavaScript
Raw Normal View History

2026-08-08 14:13:28 +08:00
const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
2026-08-18 16:03:42 +08:00
import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
2026-08-17 19:51:54 +08:00
import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
2026-08-08 14:13:28 +08:00
function id(value) { return value == null ? '' : String(value) }
2026-08-18 16:03:42 +08:00
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') }
2026-08-08 14:13:28 +08:00
async function request(context, method, path, body, headers = {}) {
try {
2026-08-18 16:03:42 +08:00
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') }
2026-08-08 14:13:28 +08:00
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
2026-08-18 16:03:42 +08:00
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 },
],
})
2026-08-17 13:24:48 +08:00
}
2026-08-18 16:03:42 +08:00
function transportSnapshot(value) {
return ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
.reduce((out, key) => { out[key] = value?.[key]; return out }, {})
2026-08-14 10:57:05 +08:00
}
2026-08-18 16:03:42 +08:00
function hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') }
2026-08-14 10:57:05 +08:00
2026-08-08 14:13:28 +08:00
export async function run(context) {
2026-08-13 15:59:05 +08:00
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
2026-08-18 16:03:42 +08:00
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)
2026-08-08 14:13:28 +08:00
if (!bookingId) return
2026-08-18 16:03:42 +08:00
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 })) }
2026-08-08 14:13:28 +08:00
}