const MODULE = 'phase4-cross-region-events' const OPTIONS = { scenario: 'phase4-cross-region-events', deploymentBlocker: true } function id(value) { return value == null ? '' : String(value) } function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function denied(response) { return response?.http === 401 || response?.http === 403 || ['401', '403', 'A0401', 'A0403'].includes(String(response?.body?.code)) } function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) } function evidence(response, extra = '') { return `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function blockingGap(context, name, reason, category, response = null) { context.addCoverageGap(MODULE, OPTIONS.scenario, name, reason, category, { deploymentBlocker: true }) context.addStep(MODULE, name, response, false, reason, OPTIONS) throw new Error(`blocking coverage gap: ${name}: ${reason}`) } async function poll(request, matches, attempts = 12) { let last = null for (let index = 0; index < attempts; index += 1) { last = await request() if (matches(last)) return { response: last, attempts: index + 1 } if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000)) } return { response: last, attempts } } function sameIds(left, right) { const ids = (value) => records(value).map((item) => id(item?.id)).filter(Boolean).sort() return JSON.stringify(ids(left)) === JSON.stringify(ids(right)) } export async function run(context) { if (!context.token || !context.overseasToken) { blockingGap(context, '双Token前置', '国内或海外访问令牌缺失,不能执行跨区安全门禁', 'missing-dual-token') } const domesticTokenAgainstOverseas = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, {}, context.token) context.assert(MODULE, '国内Token不能访问海外Audience', domesticTokenAgainstOverseas, denied(domesticTokenAgainstOverseas) && traced(domesticTokenAgainstOverseas), evidence(domesticTokenAgainstOverseas), OPTIONS) const phase3 = context.createdResources?.phase3Loading if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded || !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) { blockingGap(context, '国内装柜完成事件前置', '缺少本轮marker的LOADED装柜事实;必须先完成三期装柜链,禁止借用历史事件', 'missing-marker-loading-completed') } const overseasTokenAgainstDomestic = await context.request('GET', `/api/platform/v1/containers/${encodeURIComponent(id(phase3.containerBookingId))}`, undefined, context.overseasToken) context.assert(MODULE, '海外Token不能访问国内Audience', overseasTokenAgainstDomestic, denied(overseasTokenAgainstDomestic) && traced(overseasTokenAgainstDomestic), evidence(overseasTokenAgainstDomestic), OPTIONS) const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}` const delivered = await poll( () => context.requestOverseas('GET', taillegPath), (response) => success(response) && records(response.body?.data) .some((item) => item?.containerNo === phase3.containerNo)) const deliveredRows = records(delivered.response?.body?.data) .filter((item) => item?.containerNo === phase3.containerNo) context.assert(MODULE, '国内装柜完成经Bridge生成Lastleg订单', delivered.response, deliveredRows.length > 0 && traced(delivered.response), evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS) if (deliveredRows.length === 0) { blockingGap(context, '国内到海外事件闭环', `本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查Bridge重试与下游诊断`, 'cn-overseas-event-not-delivered', delivered.response) } const deliveredAgain = await context.requestOverseas('GET', taillegPath) context.assert(MODULE, '装柜完成事件重试保持建单幂等', deliveredAgain, success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data) && traced(deliveredAgain), evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS) const overseasPda = context.createdResources?.phase4OverseasPda if (overseasPda?.marker !== context.marker || overseasPda?.finalStatus !== 'COMPLETED' || !overseasPda?.customerId || !overseasPda?.orderId) { blockingGap(context, '海外柜完成事件前置', '缺少本轮marker的海外PDA COMPLETED柜事实;必须先完成四期海外PDA闭环', 'missing-marker-overseas-container-completed') } const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}` const orderDetail = await context.requestOverseas('GET', orderDetailPath) const order = orderDetail.body?.data?.order || orderDetail.body?.data const completedContainerNo = order?.containerNo || '' context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail, success(orderDetail) && Boolean(completedContainerNo) && traced(orderDetail), evidence(orderDetail, `orderId=${overseasPda.orderId}, containerNo=${completedContainerNo || 'missing'}`), OPTIONS) if (!completedContainerNo) { blockingGap(context, '海外完成柜来源身份', '尾程订单未返回containerNo,无法核对国内回写', 'missing-overseas-container-identity', orderDetail) } const containerPagePath = `/api/platform/v1/containers?containerNo=${encodeURIComponent(completedContainerNo)}&page=1&size=20` const containerPage = await context.request('GET', containerPagePath) const domesticMatches = records(containerPage.body?.data) .filter((item) => item?.containerNo === completedContainerNo) context.assert(MODULE, '物理柜号唯一定位国内柜聚合', containerPage, success(containerPage) && domesticMatches.length === 1 && traced(containerPage), evidence(containerPage, `containerNo=${completedContainerNo}, matches=${domesticMatches.length}`), OPTIONS) if (domesticMatches.length !== 1) { blockingGap(context, '海外到国内柜身份映射', `物理柜号${completedContainerNo}匹配国内柜数量为${domesticMatches.length},无法验证回写`, 'domestic-container-identity-not-unique', containerPage) } const overviewPath = `/api/platform/v1/containers/${encodeURIComponent(id(domesticMatches[0].id))}/overview` const writtenBack = await poll( () => context.request('GET', overviewPath), (response) => { const container = response?.body?.data?.container return success(response) && Boolean(container?.unloadingCompletedAt) && Boolean(container?.unloadingPhotoUploadedAt) && Boolean(container?.estimatedEmptyReportedAt) }) const snapshot = writtenBack.response?.body?.data?.container || {} const completedAt = Date.parse(snapshot.unloadingCompletedAt) const uploadedAt = Date.parse(snapshot.unloadingPhotoUploadedAt) const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt) const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt) && Number.isFinite(estimatedAt) && completedAt >= uploadedAt && estimatedAt - uploadedAt === 2 * 24 * 60 * 60 * 1_000 context.assert(MODULE, '海外柜完成经Bridge回写Firstleg时间事实', writtenBack.response, validTimeline && traced(writtenBack.response), evidence(writtenBack.response, `pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS) if (!validTimeline) { blockingGap(context, '海外到国内事件闭环', `柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查Bridge重试与诊断证据`, 'overseas-cn-event-not-delivered', writtenBack.response) } const writtenBackAgain = await context.request('GET', overviewPath) const replaySnapshot = writtenBackAgain.body?.data?.container || {} const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt', 'actualEmptyReportedAt', 'emptyReportSourceVersion'] context.assert(MODULE, '海外柜完成事件重试保持回写幂等', writtenBackAgain, success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null)) && traced(writtenBackAgain), evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS) }