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']) 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 nullableId(value) { return value == null || Boolean(id(value)) } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } export function markerOwnedCarrierQuery(context) { const resource = context?.createdResources?.phase3CarrierScheduleQuery return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker ? resource : null } function validTask(task) { return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) && QUERY_STATUSES.includes(task?.status) && nullableId(task?.containerBookingId) && 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 Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode) && STANDARD_NODES.includes(node?.standardNode) && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 && 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 Boolean(id(value?.id) && 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 Boolean(id(item?.id) && item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) && nullableText(item?.reason) && nullableId(item?.operatorId) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } export async function run(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, '船节点分页结构稳定', 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 ownedResource = markerOwnedCarrierQuery(context) if (records.length === 0 && !ownedResource) { context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse, Number(page?.total) === 0 && Number(page?.pages) === 0, `records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS) return } const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) 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, '详情返回Task、标准节点与业务历史', detailResponse, 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), `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) const task = detail.query.query if (!ownedResource) { context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse, true, `readOnlyQueryId=${queryId}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse, Array.isArray(history) && history.every(validHistory), `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) return } const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse, ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS) if (!ownershipMatches) return const observedAt = task.latestActualAt || '2026-08-21T00:00:00' const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { expectedVersion: task.version, standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE', observedAt, estimatedAt: observedAt, actualAt: null, locationCode: null, locationName: null, reason: '四期船节点人工预计事实验收', }, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) }) const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse, QUERY_STATUSES.includes(manualDetail?.query?.query?.status) && manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL' && item?.estimatedAt != null && item?.actualAt == null), `status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '独立历史端点返回可审计操作者与nullable字段', historyResponse, Array.isArray(history) && history.every(validHistory), `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) }