优化
This commit is contained in:
@@ -2,20 +2,13 @@ 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 PORT = '/api/platform/v1/port-schedules'
|
||||
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) }
|
||||
export function matchesManualScheduleScope(resource, expected) {
|
||||
return Boolean(resource?.id)
|
||||
&& id(resource.customerId) === id(expected.customerId)
|
||||
&& id(resource.oceanBookingId) === id(expected.oceanBookingId)
|
||||
&& id(resource.containerBookingId) === id(expected.containerBookingId)
|
||||
}
|
||||
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 at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) }
|
||||
function step(context, name, response, passed, evidence) {
|
||||
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
|
||||
}
|
||||
@@ -39,12 +32,23 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
@@ -56,115 +60,125 @@ function oceanDraft(context, suffix, master) {
|
||||
|
||||
async function ensureOcean(context, suffix, master) {
|
||||
const existing = context.createdResources?.oceanBooking
|
||||
if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null }
|
||||
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)
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS)
|
||||
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
|
||||
}
|
||||
|
||||
async function createManualSchedule(context, suffix, oceanBookingId, customerId) {
|
||||
const existing = context.createdResources?.portSchedule
|
||||
const expectedScope = { customerId, oceanBookingId, containerBookingId: null }
|
||||
if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id)
|
||||
const body = {
|
||||
expectedVersion: 0,
|
||||
data: {
|
||||
customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
||||
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
|
||||
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60),
|
||||
customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60),
|
||||
observedAt: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker,
|
||||
},
|
||||
reason: '船司官网人工查询兜底',
|
||||
}
|
||||
const response = await request(context, 'POST', PORT, body,
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS)
|
||||
return id(detail?.portSchedule?.schedule?.id)
|
||||
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, 'ZIM官方API自动查询',
|
||||
'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键',
|
||||
'external-third-party')
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
|
||||
'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功',
|
||||
'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, '六家官方来源及合规模式完整', carriersResponse,
|
||||
context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse,
|
||||
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
|
||||
&& byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true
|
||||
&& byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
|
||||
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true
|
||||
&& 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') && item.automated === false),
|
||||
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
|
||||
&& [...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: 'BILL_OF_LADING',
|
||||
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
|
||||
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, '创建真实船司查询任务', createResponse, '', OPTIONS)
|
||||
const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS)
|
||||
const queryId = id(created?.query?.query?.id)
|
||||
context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse,
|
||||
context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse,
|
||||
Boolean(queryId) && created?.query?.query?.status === 'READY'
|
||||
&& created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API',
|
||||
`queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS)
|
||||
&& 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, '创建任务幂等重放返回同一资源', replay,
|
||||
context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay,
|
||||
id(data(replay)?.query?.query?.id) === queryId,
|
||||
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const version = Number(created.query.query.version)
|
||||
|
||||
const unknownCarrier = await request(context, 'POST', BASE,
|
||||
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` },
|
||||
{ ...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 forgedCustomer = await request(context, 'POST', BASE,
|
||||
{ ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) })
|
||||
step(context, '伪造客户与订舱关联被拒绝', forgedCustomer,
|
||||
forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)),
|
||||
`HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`)
|
||||
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 scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId)
|
||||
if (!scheduleId) return
|
||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
|
||||
{ expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) })
|
||||
const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS)
|
||||
context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual,
|
||||
completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
|
||||
&& Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt)
|
||||
&& completed?.query?.expired === false
|
||||
&& completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
|
||||
`status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, 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, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse,
|
||||
!Object.hasOwn(detail || {}, 'attempts')
|
||||
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_RESULT'),
|
||||
`attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
|
||||
&& 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user