const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' const OCEAN = '/api/platform/v1/ocean-bookings' const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) 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 } function localIso(value) { return value.toISOString().slice(0, 19) } 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 } } } export function ownsCarrierQuery(resource, marker) { return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker) } export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) { const configured = String(explicit || '').trim().toUpperCase() if (configured) return configured let hash = 0x811c9dc5 for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}` } 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 ensureOcean(context, suffix, master) { const existing = context.createdResources?.oceanBooking if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) { return { id: id(existing.id), containerBookingId: null } } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS) return { id: id(detail?.oceanBooking?.id), containerBookingId: null } } function nodeSnapshot(detail) { return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId, standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, })) } export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', 'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) const carriersResponse = await request(context, 'GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS) const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) && AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true && byCode.get(code)?.coverageGap === false) && byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')), `automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS) const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const ocean = await ensureOcean(context, suffix, master) if (!ocean.id) return const trackingNo = markerTrackingNo(context.marker) const createBody = { expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id, containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', trackingNo, remark: context.marker, } const createKey = `${context.marker}-carrier-query-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse, Boolean(queryId) && created?.query?.query?.status === 'READY' && created?.query?.query?.carrierCode === 'ZIM' && created?.query?.query?.trackingType === 'CONTAINER_NO' && created?.query?.query?.trackingNo === trackingNo, `queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS) if (!queryId) return context.createdResources.phase3CarrierScheduleQuery = { queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId), oceanBookingId: ocean.id, trackingNo, } const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay, id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) const unknownCarrier = await request(context, 'POST', BASE, { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` }, { 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) }) step(context, '非法船司代码被拒绝', unknownCarrier, unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)), `HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`) const initialTask = created.query.query const observedAt = localIso(new Date()) const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) const manualBody = { expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', reason: `本轮marker预计节点验收 ${context.marker}`, } const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128) const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, { 'Idempotency-Key': manualKey }) const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS) const manualTask = manualDetail?.query?.query context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual, id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version) && manualTask?.status === initialTask.status && manualTask?.latestActualNode === initialTask.latestActualNode && manualTask?.latestActualAt === initialTask.latestActualAt && manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), `version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS) const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, { 'Idempotency-Key': manualKey }) const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS) context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual, Number(replayDetail?.query?.query?.version) === Number(manualTask?.version) && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)), `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, { 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) }) step(context, '旧version配合新幂等键被A0409拒绝', stale, stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`) const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS) context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse, Number(detail?.query?.query?.version) === Number(manualTask?.version) && detail?.query?.query?.latestActualNode === manualTask?.latestActualNode && detail?.query?.query?.latestActualAt === manualTask?.latestActualAt && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail)) && !Object.hasOwn(detail || {}, 'attempts') && detail?.histories?.some((item) => item.action === 'CREATE') && detail?.histories?.some((item) => item.action === 'MANUAL_NODE'), `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version) }