228 lines
15 KiB
JavaScript
228 lines
15 KiB
JavaScript
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')
|
|
}
|