import { portScheduleFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'phase3-port-schedules' const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/port-schedules' const OCEAN = '/api/platform/v1/ocean-bookings' const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches' import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } const SHANGHAI_TIME = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', }) function localIso(value) { const parts = Object.fromEntries(SHANGHAI_TIME.formatToParts(value) .filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])) return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}` } function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) } function step(context, name, response, passed, evidence) { context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed }) } 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 oceanDraft(context, suffix, master) { return { ...oceanBookingDraft(context, suffix, master), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }, ], } } async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) { const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id) if (existingId) { const response = await request(context, 'GET', `${OCEAN}/${existingId}`) const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response, `oceanBookingId=${existingId}`, OPTIONS) return detail?.oceanBooking ? { id: existingId, customerId: id(detail.oceanBooking.customerId), allocatedContainerBookingIds: (detail.containers || []) .map((item) => id(item?.containerBookingId)).filter(Boolean), } : null } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS) const oceanBookingId = id(detail?.oceanBooking?.id) context.assert(MODULE, '船期关联订舱客户归属明确', response, Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId), `oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) return oceanBookingId ? { id: oceanBookingId, customerId: id(detail.oceanBooking.customerId), allocatedContainerBookingIds: (detail.containers || []) .map((item) => id(item?.containerBookingId)).filter(Boolean), } : null } function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) { return { customerId, oceanBookingId, containerBookingId: containerBookingId || null, sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`, vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60), customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60), observedAt: expired ? at(-120) : at(-1), expiresAt: expired ? at(-60) : at(60), remark: context.marker, } } export async function run(context) { // 官网船期查询任务、频率限制和 FAILED/EXPIRED 终态由 // phase3-carrier-schedule-queries 模块通过真实 HTTP 契约验收;此处只保留 // 仍受第三方交互能力限制的 ZIM/COSCO 外部缺口,避免重复报告已实现能力。 context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM 官网 hCaptcha 自动查询', 'ZIM hCaptcha 需要第三方交互能力,仓库未公开 provider/task 契约;保留为外部依赖缺口', 'external-third-party') context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO 动态页面自动查询', 'COSCO 动态页面没有已公开 provider/task 契约或稳定机器接口;保留为外部依赖缺口', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) const probe = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) if (probe.http === 404 || probe.http === 405 || probe.http === 503 || probe.http === 0) { step(context, '港务船期真实平台契约已部署', probe, false, `入口 ${BASE} 不可用:HTTP=${probe.http}, code=${codeOf(probe)}`) return } const page = context.apiSuccess(MODULE, '港务船期分页入口可用', probe, '', OPTIONS) context.assert(MODULE, '港务船期分页结构稳定', probe, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS) const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const flow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId) if (!ocean?.id) return const containerBookingId = id(flow?.containerBookingId) || null const knownAllocation = containerBookingId && ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null if (containerBookingId && !knownAllocation) { context.addStep(MODULE, '船期订舱真实分配包含本轮装货柜次', null, false, `oceanBookingId=${ocean.id}, containerBookingId=${containerBookingId}, allocations=${ocean.allocatedContainerBookingIds?.join(',') || 'none'}`, OPTIONS) return } const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker } const createKey = `${context.marker}-port-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS) const scheduleId = id(created?.portSchedule?.schedule?.id) const scheduleFieldGaps = portScheduleFieldGaps(created) context.assert(MODULE, '船期来源、时效、截单截关截港和关联字段完整', createResponse, Boolean(scheduleId) && created?.portSchedule?.expired === false && created?.portSchedule?.schedule?.sourceType === 'CARRIER' && id(created?.portSchedule?.schedule?.oceanBookingId) === ocean.id && Boolean(created?.portSchedule?.schedule?.observedAt) && Boolean(created?.portSchedule?.schedule?.updatedAt) && Boolean(created?.portSchedule?.schedule?.expiresAt) && Boolean(created?.portSchedule?.schedule?.bookingCutoffAt) && Boolean(created?.portSchedule?.schedule?.customsCutoffAt) && Boolean(created?.portSchedule?.schedule?.portCutoffAt), `scheduleId=${scheduleId || 'missing'}, expired=${created?.portSchedule?.expired ?? 'missing'}`, OPTIONS) context.assert(MODULE, '船期列表来源、船名航次、港口与关键时间字段格式完整', createResponse, scheduleFieldGaps.length === 0, `missing=${scheduleFieldGaps.join(',') || 'none'}`, OPTIONS) if (!scheduleId) return const replay = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey }) context.assert(MODULE, '新增船期 Idempotency-Key 重放返回同一资源', replay, id(data(replay)?.portSchedule?.schedule?.id) === scheduleId, `first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS) for (const [name, query] of [ ['按客户查询船期', `customerId=${encodeURIComponent(ocean.customerId)}`], ['按订舱查询船期', `oceanBookingId=${ocean.id}`], ...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []), ]) { const response = await request(context, 'GET', `${BASE}?page=1&size=20&${query}`) const records = data(response)?.records step(context, name, response, response.http === 200 && codeOf(response) === '200' && Array.isArray(records) && records.some((item) => id(item?.schedule?.id) === scheduleId), `records=${records?.length ?? 'n/a'}, scheduleId=${scheduleId}`) } const initialVersion = Number(created.portSchedule.schedule.version) const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true) expiredData.sourceRef = payload.data.sourceRef const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' }, { 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) }) const updated = context.apiSuccess(MODULE, '更新船期并计算过期状态', updateResponse, '', OPTIONS) context.assert(MODULE, '过期标识由expiresAt服务端计算且版本递增', updateResponse, updated?.portSchedule?.expired === true && Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1, `expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' }, { 'Idempotency-Key': `${context.marker}-port-stale`.slice(0, 128) }) step(context, '旧版本更新船期返回乐观锁冲突', stale, stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`) const expiredQuery = await request(context, 'GET', `${BASE}?page=1&size=20&expired=true&oceanBookingId=${ocean.id}`) const expiredRecords = data(expiredQuery)?.records step(context, '按过期标识查询船期', expiredQuery, expiredQuery.http === 200 && codeOf(expiredQuery) === '200' && Array.isArray(expiredRecords) && expiredRecords.some((item) => id(item?.schedule?.id) === scheduleId && item?.expired === true), `records=${expiredRecords?.length ?? 'n/a'}`) const detailResponse = await request(context, 'GET', `${BASE}/${scheduleId}`) const detail = context.apiSuccess(MODULE, '回查港务船期详情与历史', detailResponse, '', OPTIONS) context.assert(MODULE, '船期详情保留来源更新时间和CREATE/UPDATE历史', detailResponse, detail?.portSchedule?.expired === true && detail?.histories?.length >= 2 && detail.histories.some((item) => item.action === 'CREATE') && detail.histories.some((item) => item.action === 'UPDATE') && detail.histories.every((item) => item.operatorId && item.createdAt), `histories=${detail?.histories?.length ?? 'n/a'}`, OPTIONS) const historyResponse = await request(context, 'GET', `${BASE}/${scheduleId}/history`) const histories = context.apiSuccess(MODULE, '独立查询船期不可变历史', historyResponse, '', OPTIONS) context.assert(MODULE, '船期历史端点顺序和版本字段稳定', historyResponse, Array.isArray(histories) && histories.length >= 2 && histories[0]?.action === 'CREATE' && histories.at(-1)?.toVersion === initialVersion + 1, `histories=${histories?.length ?? 'n/a'}`, OPTIONS) const inboundId = id(flow?.inboundId) if (knownAllocation && inboundId) { const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS) const scheduleGate = expiredGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') context.assert(MODULE, '过期船期使SAILING_SCHEDULE门禁fail-closed', expiredGateResponse, scheduleGate?.passed === false && scheduleGate?.actual === 'EXPIRED', `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) } const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false) currentData.sourceRef = payload.data.sourceRef currentData.sourceType = 'MANUAL' currentData.sourceName = `平台人工兜底-${suffix}` const refreshResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion + 1, data: currentData, reason: '刷新为当前有效船期' }, { 'Idempotency-Key': `${context.marker}-port-refresh`.slice(0, 128) }) const refreshed = context.apiSuccess(MODULE, '刷新船期为国内出仓可用终态', refreshResponse, '', OPTIONS) context.assert(MODULE, '刷新后船期有效且版本再次递增', refreshResponse, refreshed?.portSchedule?.expired === false && refreshed?.portSchedule?.schedule?.sourceType === 'MANUAL' && refreshed?.portSchedule?.schedule?.sourceName === currentData.sourceName && Number(refreshed?.portSchedule?.schedule?.version) === initialVersion + 2, `expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) if (knownAllocation && inboundId) { const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS) const scheduleGate = readyGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') context.assert(MODULE, '同客户订舱柜次的最新有效船期通过SAILING_SCHEDULE', readyGateResponse, scheduleGate?.passed === true && String(scheduleGate?.actual || '').startsWith('READY:scheduleId='), `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) } const forged = await request(context, 'POST', BASE, { expectedVersion: 0, data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, ocean.customerId), customerId: `${ocean.customerId}999` }, reason: '伪造客户归属', }, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) }) step(context, '伪造船期客户归属被拒绝', forged, forged.http === 200 && ['A0400', '400'].includes(codeOf(forged)), `HTTP=${forged.http}, code=${codeOf(forged)}`) context.createdResources.portSchedule = { id: scheduleId, oceanBookingId: ocean.id, containerBookingId: knownAllocation, inboundId: inboundId || null, expired: false, gateReady: Boolean(knownAllocation && inboundId), } }