const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) export const CARRIER_EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) function stable(value) { if (Array.isArray(value)) return value.map(stable) if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) return value } const equal = (a, b) => JSON.stringify(stable(a)) === JSON.stringify(stable(b)) const rows = value => [...value].sort((a, b) => id(a.id).localeCompare(id(b.id))) export function carrierExceptionShapeErrors(detail) { const errors = [] if (!Array.isArray(detail?.nodes) || !Array.isArray(detail?.exceptions) || !Array.isArray(detail?.histories)) return ['missing detail arrays'] const identities = new Set() for (const fact of detail.exceptions) { const identity = `${fact.providerCode}|${fact.providerEventId}` if (!validLongId(fact.id) || !CARRIER_EXCEPTION_CODES.includes(fact.exceptionCode) || typeof fact.active !== 'boolean' || !fact.providerCode || !fact.providerEventId || !fact.providerStatus || !fact.observedAt || !fact.createdAt || !/^[a-f0-9]{64}$/.test(fact.rawPayloadHash || '')) errors.push(`invalid exception ${id(fact.id)}`) for (const [key, max] of [['locationCode', 32], ['locationName', 128], ['sourceReference', 500]]) { if (fact[key] != null && (typeof fact[key] !== 'string' || fact[key].length > max)) errors.push(`invalid optional ${key}`) } if (identities.has(identity)) errors.push(`duplicate provider event ${identity}`) identities.add(identity) } if (detail.nodes.some(node => CARRIER_EXCEPTION_CODES.includes(node.standardNode))) errors.push('exception stored as normal node') return errors } // A GET is evidence only. Supplier writes/replays must be orchestrated separately // through Integration + the real scheduler; no synthetic platform write endpoint. export async function captureCarrierExceptionEvidence(context, resource = context.createdResources?.phase3CarrierScheduleQuery) { if (!ownsCarrierQuery(resource, context.marker)) throw new Error('requires current-marker owned carrier query') const response = await request(context, 'GET', `${BASE}/${encodeURIComponent(resource.queryId)}`) const detail = context.apiSuccess(MODULE, 'C66读取原船任务正常节点和独立异常事实', response, '', OPTIONS) if (!detail || id(detail.query?.query?.id) !== id(resource.queryId)) throw new Error('carrier detail identity mismatch') const errors = carrierExceptionShapeErrors(detail) context.assert(MODULE, 'C66异常编码来源地点摘要完整且不混入正常节点', response, errors.length === 0, errors.join('; '), OPTIONS) if (errors.length) throw new Error('invalid carrier exception facts') return { marker: context.marker, queryId: id(resource.queryId), capturedAt: new Date().toISOString(), requestId: response.requestId, traceId: response.traceId, detail } } export function carrierExceptionTransitionErrors(before, after, { kind, expected }) { const errors = [...carrierExceptionShapeErrors(before?.detail), ...carrierExceptionShapeErrors(after?.detail)] if (!before?.marker || before.marker !== after?.marker || !before.queryId || before.queryId !== after.queryId || id(before.detail?.query?.query?.id) !== before.queryId || id(after.detail?.query?.query?.id) !== before.queryId) errors.push('checkpoint identity mismatch') if (errors.length) return errors const a = before.detail, b = after.detail if (!a.nodes.some(node => node.actualAt) || !a.nodes.some(node => node.estimatedAt)) errors.push('requires actual and estimated normal baseline') if (!equal(rows(a.nodes), rows(b.nodes))) errors.push('normal actual/estimated facts changed') for (const key of ['id', 'customerId', 'oceanBookingId', 'containerBookingId', 'latestStandardNode', 'latestNodeObservedAt', 'stoppedAt', 'stopReason']) { if (!equal(a.query.query[key], b.query.query[key])) errors.push(`normal query scope/progress changed: ${key}`) } const histories = detail => rows(detail.histories.filter(item => item.action === 'EXCEPTION_OBSERVED')) if (kind === 'REPLAY' || kind === 'CONFLICT') { const original = expected && a.exceptions.find(item => item.providerCode === expected.providerCode && item.providerEventId === expected.providerEventId) if (!original) errors.push('original event absent before replay/conflict') if (!/^[a-f0-9]{64}$/.test(expected?.rawPayloadHash || '') || (original && (kind === 'REPLAY' ? original.rawPayloadHash !== expected.rawPayloadHash : original.rawPayloadHash === expected.rawPayloadHash))) errors.push('replay/conflict payload hash does not match scenario') if (!equal(rows(a.exceptions), rows(b.exceptions)) || !equal(histories(a), histories(b))) errors.push('replay/conflict changed exception facts/history') } else if (kind === 'APPEND' || kind === 'RELEASE') { if (!expected || !CARRIER_EXCEPTION_CODES.includes(expected.exceptionCode) || expected.active !== (kind === 'APPEND')) errors.push('invalid expected supplier fact') const added = b.exceptions.filter(item => !a.exceptions.some(old => id(old.id) === id(item.id))) if (added.length !== 1 || !expected || !Object.entries(expected).every(([key, value]) => equal(added[0]?.[key], value))) errors.push('expected independent fact not appended exactly once') if (!a.exceptions.every(old => b.exceptions.some(item => equal(old, item)))) errors.push('original exception overwritten/deleted') if (kind === 'RELEASE' && !a.exceptions.some(item => item.providerCode === expected?.providerCode && item.exceptionCode === expected?.exceptionCode && item.active === true)) errors.push('release lacks original active fact') const oldHistory = histories(a), newHistory = histories(b) if (newHistory.length !== oldHistory.length + 1 || !oldHistory.every(old => newHistory.some(item => equal(old, item)))) errors.push('exception history not append-only') } else errors.push('unsupported transition') return errors } export async function verifyCarrierExceptionTransition(context, before, transition) { const after = await captureCarrierExceptionEvidence(context) const errors = carrierExceptionTransitionErrors(before, after, transition) context.assert(MODULE, `C66 ${transition.kind}异常独立保存且正常实际预计节点零漂移`, null, errors.length === 0, errors.join('; '), OPTIONS) context.createdResources.carrierExceptionTransitions ??= [] // Zero drift does not prove that an upstream replay/conflict was actually sent. context.createdResources.carrierExceptionTransitions.push({ before, after, transition, errors, supplierDeliveryProofRequired: true, completionClaim: false }) return after } 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 } 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 currentOutboundFixture(context) { const loading = context?.createdResources?.phase3Loading const outbound = context?.createdResources?.phase3OutboundDispatch const marker = context?.marker const matches = loading?.marker === marker && outbound?.marker === marker && id(loading?.containerBookingId) && id(loading?.containerBookingId) === id(outbound?.containerBookingId) && id(loading?.oceanBookingId) && id(loading?.containerNo) && outbound?.confirmedStatus === 'WAITING_FOR_LOADING' return matches ? { marker, customerId: id(outbound.tenantId || loading.tenantId), oceanBookingId: id(loading.oceanBookingId), containerBookingId: id(loading.containerBookingId), trackingNo: id(loading.containerNo), } : null } export function ownsCarrierQuery(resource, marker) { return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker && resource?.triggerSource === 'OUTBOUND_CONFIRM') } function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) } function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } export function validContainerNo(value) { if (typeof value !== 'string' || !/^[A-Z]{3}[UJZ][0-9]{7}$/.test(value)) return false const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38] let sum = 0 for (let index = 0; index < 10; index += 1) { const code = value.charCodeAt(index) const number = code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65] sum += number * (2 ** index) } return Number(value[10]) === (sum % 11) % 10 } export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', 'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF', 'external-third-party') 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')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED', `automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS) const fixture = currentOutboundFixture(context) if (!fixture) { blocker(context, '缺少本轮phase3出仓fixture,禁止借历史Query验收', `marker=${context.marker}, loading=${context.createdResources?.phase3Loading?.marker || 'missing'}, outbound=${context.createdResources?.phase3OutboundDispatch?.marker || 'missing'}`) return } const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${fixture.oceanBookingId}`) const booking = context.apiSuccess(MODULE, '读取船任务所属订舱权威客户范围', bookingResponse, '', OPTIONS) if (!booking?.oceanBooking || id(booking.oceanBooking.id) !== fixture.oceanBookingId) return fixture.customerId = booking.oceanBooking.customerId == null ? '0' : id(booking.oceanBooking.customerId) const selector = new URLSearchParams({ customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20', }) const pageResponse = await request(context, 'GET', `${BASE}?${selector}`) const page = context.apiSuccess(MODULE, '查询本轮出仓自动创建的船司任务', pageResponse, '', OPTIONS) const matches = (Array.isArray(page?.records) ? page.records : []).filter((item) => { const task = item?.query return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId && id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo && task?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo) && task?.triggerSource === 'OUTBOUND_CONFIRM' }) context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse, matches.length === 1, `matches=${matches.length}, total=${page?.total ?? 'missing'}`, OPTIONS) if (matches.length !== 1) return const task = matches[0].query const queryId = id(task.id) const automatedCarrier = AUTOMATED_CARRIERS.includes(task?.carrierCode) const manualCarrier = task?.carrierCode === 'OTHER' || !AUTOMATED_CARRIERS.includes(task?.carrierCode) context.assert(MODULE, '出仓自动建任务兼容自动船司与未知船司人工处理', pageResponse, (automatedCarrier && ['READY', 'MANUAL_REQUIRED'].includes(task?.status)) || (manualCarrier && task?.status === 'MANUAL_REQUIRED' && Boolean(task?.manualReason)), `carrierCode=${task?.carrierCode || 'missing'}, status=${task?.status || 'missing'}, manualReason=${task?.manualReason || 'none'}`, OPTIONS) const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '回查本轮出仓船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) const departed = (detail?.nodes || []).filter((item) => item.standardNode === 'DEPARTED_WAREHOUSE' && item.actualAt != null && item.estimatedAt == null) context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse, id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id) && detail?.query?.query?.trackingType === 'CONTAINER_NO' && validContainerNo(detail?.query?.query?.trackingNo) && departed.length >= 1 && departed.every((item) => validLongId(item.id) && item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode && typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), `queryId=${queryId}, departed=${departed.length}`, OPTIONS) context.createdResources.phase3CarrierScheduleQuery = { queryId, marker: context.marker, ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM', customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId, containerBookingId: fixture.containerBookingId, trackingNo: fixture.trackingNo, carrierCode: task?.carrierCode, initialStatus: task?.status, version: Number(data(detailResponse)?.query?.query?.version), } context.createdResources.carrierExceptionBaseline = await captureCarrierExceptionEvidence(context) context.addCoverageGap(MODULE, 'carrier-exception-provider-lifecycle', 'C66供应商异常新增解除及真实重放冲突', '正式平台API仅支持正常manual-nodes/manual-results;异常来自Integration query结果经真实scheduler写入。需总控受控供应商投递APPEND/REPLAY/CONFLICT/RELEASE并保存执行证据后调用只读checkpoint核验,空数组或重复GET不证明幂等。', 'external-third-party') }