Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs
T

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')
}