120 lines
7.1 KiB
JavaScript
120 lines
7.1 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'])
|
|
|
|
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 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 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?.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)
|
|
&& 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),
|
|
}
|
|
}
|