2026-08-10 12:20:05 +08:00
const MODULE = 'phase3-carrier-schedule-queries'
const OPTIONS = { scenario : 'carrier-schedule-query-policy' , deploymentBlocker : true }
const BASE = '/api/platform/v1/carrier-schedule-queries'
2026-08-21 17:58:52 +08:00
const AUTOMATED_CARRIERS = Object . freeze ([ 'MATSON' , 'SML' , 'ZIM' , 'EMC' , 'HEDE' ])
2026-10-03 06:26:33 +08:00
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
}
2026-08-10 12:20:05 +08:00
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 }
}
}
2026-08-24 19:44:28 +08:00
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
}
2026-08-21 17:58:52 +08:00
export function ownsCarrierQuery ( resource , marker ) {
2026-08-24 19:44:28 +08:00
return Boolean ( resource ? . ownedByModule && resource ? . queryId && resource ? . marker === marker
&& resource ? . triggerSource === 'OUTBOUND_CONFIRM' )
2026-08-21 17:58:52 +08:00
}
2026-08-24 19:44:28 +08:00
function blocker ( context , name , evidence ) {
context . addStep ( MODULE , name , null , false , evidence , OPTIONS )
2026-08-21 17:58:52 +08:00
}
2026-08-24 19:44:28 +08:00
function validLongId ( value ) { return typeof value === 'string' && /^\d{16,}$/ . test ( value ) }
2026-09-01 12:02:57 +08:00
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
}
2026-08-10 12:20:05 +08:00
export async function run ( context ) {
2026-08-21 16:04:16 +08:00
context . addCoverageGap ( MODULE , OPTIONS . scenario , 'COSCO官方API自动查询' ,
2026-08-21 17:58:52 +08:00
'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF' ,
2026-08-10 12:20:05 +08:00
'external-third-party' )
const carriersResponse = await request ( context , 'GET' , ` ${ BASE } /carriers` )
2026-08-24 19:44:28 +08:00
const carriers = context . apiSuccess ( MODULE , '查询六家船司自动能力' , carriersResponse , '' , OPTIONS )
2026-08-10 12:20:05 +08:00
const byCode = new Map (( Array . isArray ( carriers ) ? carriers : []). map (( item ) => [ item . carrierCode , item ]))
2026-08-21 17:58:52 +08:00
context . assert ( MODULE , '五家自动Provider与COSCO覆盖缺口精确' , carriersResponse ,
2026-08-10 12:20:05 +08:00
[ 'MATSON' , 'SML' , 'ZIM' , 'COSCO' , 'EMC' , 'HEDE' ]. every (( code ) => byCode . has ( code ))
2026-08-21 17:58:52 +08:00
&& AUTOMATED_CARRIERS . every (( code ) => byCode . get ( code ) ? . automated === true
&& byCode . get ( code ) ? . coverageGap === false )
&& byCode . get ( 'COSCO' ) ? . automated === false && byCode . get ( 'COSCO' ) ? . coverageGap === true
2026-08-24 19:44:28 +08:00
&& byCode . get ( 'COSCO' ) ? . coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' ,
2026-08-21 17:58:52 +08:00
`automated= ${ AUTOMATED_CARRIERS . filter (( code ) => byCode . get ( code ) ? . automated ). join ( ',' ) || 'none' } ` , OPTIONS )
2026-08-10 12:20:05 +08:00
2026-08-24 19:44:28 +08:00
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
2026-08-10 12:20:05 +08:00
}
2026-08-24 19:44:28 +08:00
2026-10-03 06:26:33 +08:00
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 )
2026-08-24 19:44:28 +08:00
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
2026-09-01 12:02:57 +08:00
&& task ? . trackingType === 'CONTAINER_NO' && validContainerNo ( task ? . trackingNo )
2026-08-24 19:44:28 +08:00
&& 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 )
2026-08-26 14:56:33 +08:00
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 )
2026-08-24 19:44:28 +08:00
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 )
2026-09-01 12:02:57 +08:00
&& detail ? . query ? . query ? . trackingType === 'CONTAINER_NO'
&& validContainerNo ( detail ? . query ? . query ? . trackingNo )
2026-08-24 19:44:28 +08:00
&& 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 )
2026-08-21 17:58:52 +08:00
context . createdResources . phase3CarrierScheduleQuery = {
2026-08-24 19:44:28 +08:00
queryId , marker : context . marker , ownedByModule : true , triggerSource : 'OUTBOUND_CONFIRM' ,
customerId : fixture . customerId , oceanBookingId : fixture . oceanBookingId ,
containerBookingId : fixture . containerBookingId , trackingNo : fixture . trackingNo ,
2026-08-26 14:56:33 +08:00
carrierCode : task ? . carrierCode , initialStatus : task ? . status ,
2026-08-24 19:44:28 +08:00
version : Number ( data ( detailResponse ) ? . query ? . query ? . version ),
2026-08-21 17:58:52 +08:00
}
2026-10-03 06:26:33 +08:00
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' )
2026-08-10 12:20:05 +08:00
}