const MODULE = 'phase4-carrier-nodes' const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'STOPPED']) export const STANDARD_NODES = Object.freeze([ 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED', ]) export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10]))) const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/ function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } function nullableText(value) { return value == null || typeof value === 'string' } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } function validLongId(value) { return typeof value === 'string' && /^[1-9]\d{15,}$/.test(value) } function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } function validCustomerScope(value) { return typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value) } function validOperatorId(value) { return value === '0' || validNumericId(value) } function validContainerNo(value) { if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.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) sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index) } return Number(value[10]) === (sum % 11) % 10 } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } function monotonicRejected(response) { return rejected(response) && String(response?.body?.message || '').includes('实际船节点必须按业务顺序向后推进') && Boolean(response?.requestId) && Boolean(response?.traceId) } function localIso(value) { return value.toISOString().slice(0, 19) } function localWallIso(value) { const pad = (part) => String(part).padStart(2, '0') return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}` + `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}` } function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)) } async function manualNodeRequest(context, queryId, body, headers) { for (let attempt = 1; attempt <= 3; attempt += 1) { const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, context.token, headers) if (response?.http !== 0 || attempt === 3) return response await sleep(600 * attempt) } } export function markerOwnedCarrierQuery(context) { const resource = context?.createdResources?.phase3CarrierScheduleQuery return resource?.ownedByModule === true && validLongId(resource.queryId) && resource.marker === context?.marker && resource.triggerSource === 'OUTBOUND_CONFIRM' ? resource : null } export function manualNodePayload({ expectedVersion, standardNode, observedAt, estimatedAt = null, actualAt = null, marker, index = 0 }) { return { expectedVersion, standardNode, observedAt, estimatedAt, actualAt, locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${marker} manual node ${String(index).padStart(2, '0')} ${standardNode}`, } } function validTask(task) { return validLongId(task?.id) && Boolean(task?.queryNo) && validCustomerScope(task?.customerId) && validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status) && validLongId(task?.containerBookingId) && task?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo) && nullableText(task?.latestActualNode) && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) && number(task?.version) && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] .every((field) => nullableIso(task?.[field])) && nullableText(task?.stopReason) } function validNode(node) { return validLongId(node?.id) && Boolean(node?.providerCode && node?.providerEventId && node?.providerNodeCode) && STANDARD_NODES.includes(node?.standardNode) && Number(node?.nodeSequence) === NODE_SEQUENCES[node.standardNode] && nullableIso(node?.observedAt) && node?.observedAt != null && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) && nullableText(node?.locationCode) && nullableText(node?.locationName) && nullableText(node?.sourceReference) && nullableIso(node?.createdAt) && typeof node?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(node.rawPayloadHash) } function validException(value) { return validLongId(value?.id) && Boolean(value?.providerCode && value?.providerEventId && value?.providerStatus) && EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean' && nullableIso(value?.observedAt) && value?.observedAt != null && nullableText(value?.locationCode) && nullableText(value?.locationName) && nullableText(value?.sourceReference) && nullableIso(value?.createdAt) && typeof value?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(value.rawPayloadHash) } function validHistory(item) { return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) && nullableText(item?.reason) && (item?.operatorId == null || validOperatorId(item.operatorId)) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } function nodeSnapshot(detail) { return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ id: id(item.id), providerEventId: item.providerEventId, standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, })) } function actualProgressSnapshot(detail) { const task = detail?.query?.query return { version: Number(task?.version), latestActualNode: task?.latestActualNode, latestActualAt: task?.latestActualAt, nodes: nodeSnapshot(detail), histories: (Array.isArray(detail?.histories) ? detail.histories : []).map((item) => ({ id: id(item?.id), action: item?.action, fromVersion: item?.fromVersion, toVersion: item?.toVersion, reason: item?.reason, createdAt: item?.createdAt, })), } } function assertDetail(context, response, detail, name) { const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] const histories = Array.isArray(detail?.histories) ? detail.histories : [] context.assert(MODULE, name, response, validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' && Array.isArray(detail?.nodes) && nodes.every(validNode) && Array.isArray(detail?.exceptions) && exceptions.every(validException) && Array.isArray(detail?.histories) && histories.every(validHistory) && !Object.hasOwn(detail || {}, 'attempts'), `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) } function blocker(context, name, evidence, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) } export async function runUntilPickupReady(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, Array.isArray(carriers) && carriers.every((item) => item?.carrierCode && item?.carrierName && nullableText(item?.officialUrl) && nullableText(item?.accessMode) && typeof item?.automated === 'boolean' && typeof item?.coverageGap === 'boolean'), `count=${Array.isArray(carriers) ? carriers.length : 'missing'}`, OPTIONS) const pageResponse = await context.request('GET', `${BASE}?page=1&size=20`) const page = context.apiSuccess(MODULE, '查询四期船节点任务分页', pageResponse, '', OPTIONS) const records = Array.isArray(page?.records) ? page.records : [] context.assert(MODULE, '船节点分页与长ID结构稳定', pageResponse, Array.isArray(page?.records) && ['page', 'size', 'total', 'pages'].every((field) => number(page?.[field])) && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) const owned = markerOwnedCarrierQuery(context) if (!owned) { blocker(context, '缺少本轮phase3出仓CarrierScheduleQuery,禁止借历史任务写节点', `marker=${context.marker}, registered=${context.createdResources?.phase3CarrierScheduleQuery?.marker || 'missing'}`, pageResponse) return } const queryId = id(owned.queryId) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) let detail = context.apiSuccess(MODULE, '查询本轮marker-owned船节点详情', detailResponse, `queryId=${queryId}`, OPTIONS) assertDetail(context, detailResponse, detail, '详情返回Task、已记录节点、异常与历史结构') const initialTask = detail?.query?.query const ownershipMatches = id(initialTask?.id) === queryId && initialTask?.trackingNo === owned.trackingNo && id(initialTask?.oceanBookingId) === id(owned.oceanBookingId) && id(initialTask?.containerBookingId) === id(owned.containerBookingId) && initialTask?.triggerSource === 'OUTBOUND_CONFIRM' context.assert(MODULE, '写入目标严格匹配本轮phase3出仓登记', detailResponse, ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS) if (!ownershipMatches) return const observedAt = localWallIso(new Date()) const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker }) const estimatedKey = `${context.marker}-carrier-estimated`.slice(0, 128) const estimatedResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, context.token, { 'Idempotency-Key': estimatedKey }) const estimatedDetail = context.apiSuccess(MODULE, '完整payload补录预计到港事实', estimatedResponse, `queryId=${queryId}`, OPTIONS) assertDetail(context, estimatedResponse, estimatedDetail, '预计事实响应保持完整DTO') context.assert(MODULE, '预计节点递增版本但不推进latestActual', estimatedResponse, Number(estimatedDetail?.query?.query?.version) > Number(initialTask.version) && estimatedDetail?.query?.query?.status !== 'STOPPED' && estimatedDetail?.query?.query?.latestActualNode === initialTask.latestActualNode && estimatedDetail?.query?.query?.latestActualAt === initialTask.latestActualAt && estimatedDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), `version=${estimatedDetail?.query?.query?.version ?? 'missing'}, latest=${estimatedDetail?.query?.query?.latestActualNode || 'none'}`, OPTIONS) const replayResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, context.token, { 'Idempotency-Key': estimatedKey }) const replayDetail = context.apiSuccess(MODULE, '同Idempotency-Key同payload重放', replayResponse, '', OPTIONS) context.assert(MODULE, '重放版本和节点集合零漂移', replayResponse, Number(replayDetail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) const hashConflict = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, { ...estimatedBody, reason: `${estimatedBody.reason} altered` }, context.token, { 'Idempotency-Key': estimatedKey }) context.addStep(MODULE, '同Idempotency-Key异payload返回A0409', hashConflict, rejected(hashConflict), `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) const staleResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, estimatedBody, context.token, { 'Idempotency-Key': `${context.marker}-carrier-stale-version`.slice(0, 128) }) context.addStep(MODULE, '旧expectedVersion配合新key返回A0409', staleResponse, rejected(staleResponse), `HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS) const zeroDriftResponse = await context.request('GET', `${BASE}/${queryId}`) detail = context.apiSuccess(MODULE, '冲突请求后回查零漂移', zeroDriftResponse, '', OPTIONS) context.assert(MODULE, '两类A0409后版本与节点集合零漂移', zeroDriftResponse, Number(detail?.query?.query?.version) === Number(estimatedDetail?.query?.query?.version) && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(estimatedDetail)), `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) let version = Number(detail.query.query.version) const frozenLatestActual = detail?.query?.query?.latestActualAt const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN : Date.parse(`${frozenLatestActual}Z`) const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY') const frozenLatestNodeIndex = STANDARD_NODES.indexOf(detail?.query?.query?.latestActualNode) context.assert(MODULE, '出仓自动实际节点作为人工补录单调起点', zeroDriftResponse, frozenLatestNodeIndex >= 0 && frozenLatestNodeIndex < pickupReadyIndex, `latest=${detail?.query?.query?.latestActualNode || 'missing'}`, OPTIONS) const sameNodeSnapshot = actualProgressSnapshot(detail) const sameNodeBody = manualNodePayload({ expectedVersion: version, standardNode: detail.query.query.latestActualNode, observedAt: localWallIso(new Date()), actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 }) const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody, context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) }) context.addStep(MODULE, '重复当前实际节点精确返回A0409', sameNodeResponse, monotonicRejected(sameNodeResponse), `HTTP=${sameNodeResponse.http}, code=${codeOf(sameNodeResponse)}, message=${sameNodeResponse.body?.message || 'missing'}`, OPTIONS) const sameNodeReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody, context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) }) context.addStep(MODULE, '同级actual失败请求同键重试仍为单调A0409且operation claim零残留', sameNodeReplay, monotonicRejected(sameNodeReplay), `HTTP=${sameNodeReplay.http}, code=${codeOf(sameNodeReplay)}, message=${sameNodeReplay.body?.message || 'missing'}`, OPTIONS) const sameNodeRead = await context.request('GET', `${BASE}/${queryId}`) detail = context.apiSuccess(MODULE, '重复当前实际节点后回查零漂移', sameNodeRead, '', OPTIONS) context.assert(MODULE, '重复当前实际节点A0409后task、node、history零漂移', sameNodeRead, JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(sameNodeSnapshot), `version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`, OPTIONS) let earlierActualChecked = false for (let index = frozenLatestNodeIndex + 1; index <= pickupReadyIndex; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) const body = manualNodePayload({ expectedVersion: version, standardNode, observedAt: localWallIso(new Date()), actualAt, marker: context.marker, index: index + 1 }) const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128) const response = await manualNodeRequest(context, queryId, body, { 'Idempotency-Key': key }) const updated = context.apiSuccess(MODULE, `补录实际节点${standardNode}`, response, `queryId=${queryId}`, OPTIONS) assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) const updatedTask = updated?.query?.query const isEmptyReturned = standardNode === 'EMPTY_RETURNED' context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt && (isEmptyReturned ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null : updatedTask.status !== 'STOPPED') && updated?.nodes?.some((item) => item.providerCode === 'MANUAL' && item.standardNode === standardNode && item.actualAt === actualAt && item.estimatedAt == null && item.providerEventId && item.providerNodeCode && item.sourceReference && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) version = Number(updatedTask.version) detail = updated if (!earlierActualChecked) { const progressedSnapshot = actualProgressSnapshot(detail) const earlierNode = STANDARD_NODES[index - 1] const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode, observedAt: localWallIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)), marker: context.marker, index }) const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody, context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) }) context.addStep(MODULE, '补录更早实际节点精确返回A0409', earlierResponse, monotonicRejected(earlierResponse), `HTTP=${earlierResponse.http}, code=${codeOf(earlierResponse)}, message=${earlierResponse.body?.message || 'missing'}`, OPTIONS) const earlierReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody, context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) }) context.addStep(MODULE, '更早actual失败请求同键重试仍为单调A0409且operation claim零残留', earlierReplay, monotonicRejected(earlierReplay), `HTTP=${earlierReplay.http}, code=${codeOf(earlierReplay)}, message=${earlierReplay.body?.message || 'missing'}`, OPTIONS) const earlierRead = await context.request('GET', `${BASE}/${queryId}`) detail = context.apiSuccess(MODULE, '更早实际节点冲突后回查零漂移', earlierRead, '', OPTIONS) context.assert(MODULE, '更早实际节点A0409后task、node、history零漂移', earlierRead, JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(progressedSnapshot), `version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`, OPTIONS) earlierActualChecked = true } } const pickupTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) const expectedPickupNodes = STANDARD_NODES.slice(0, pickupReadyIndex + 1) context.assert(MODULE, 'PDA开始前已覆盖至PICKUP_READY且任务尚未停止', detailResponse, expectedPickupNodes.every((node) => actualNodes.has(node)) && pickupTask.status !== 'STOPPED' && pickupTask.latestActualNode === 'PICKUP_READY', `actualNodes=${[...actualNodes].join(',')}, status=${pickupTask.status}`, OPTIONS) context.createdResources.phase4CarrierPickupReady = { marker: context.marker, queryId, version: Number(pickupTask.version), latestActualAt: pickupTask.latestActualAt, ready: true, } } export async function run(context) { const pickup = context.createdResources?.phase4CarrierPickupReady const drayage = context.createdResources?.phase5TerminalDrayage if (pickup?.marker !== context.marker || pickup?.ready !== true || !pickup?.queryId || drayage?.marker !== context.marker || drayage?.finalStatus !== 'EMPTY_RETURNED') { blocker(context, '取得本轮码头拖车还柜终态', `pickup=${pickup?.ready === true}, drayageMarker=${drayage?.marker || 'missing'}, drayageStatus=${drayage?.finalStatus || 'missing'}`) return } const queryId = id(pickup.queryId) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '码头拖车完成后回查本轮船节点任务', detailResponse, `queryId=${queryId}`, OPTIONS) assertDetail(context, detailResponse, detail, '码头拖车回写后任务详情DTO完整') const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) // Terminal drayage records actual pickup as the unload completion time on the // aggregate. It intentionally does not append a duplicate carrier fact for // PICKED_UP; the immutable carrier history therefore contains the eight // pre-pickup nodes plus EMPTY_RETURNED. const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP') context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点', detailResponse, carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)), `actualNodes=${[...actualNodes].join(',')}, missing=${carrierNodesWithoutInferredPickup.filter((node) => !actualNodes.has(node)).join(',')}`, OPTIONS) context.assert(MODULE, '还柜终态停止语义与本轮货柜关联', detailResponse, finalTask.status === 'STOPPED' && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' && id(finalTask.containerBookingId) === id(drayage.containerBookingId), `status=${finalTask.status}, latest=${finalTask.latestActualNode || 'none'}, ` + `stopReason=${finalTask.stopReason || 'none'}, taskBooking=${id(finalTask.containerBookingId)}, ` + `drayageBooking=${id(drayage.containerBookingId)}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) // 首个标准节点由国内出仓自动写入,PICKED_UP 由码头拖车业务回写,人工补录最多 8 条。 && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 2, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) }