const MODULE = 'phase3-outbound-dispatch' const OPTIONS = { scenario: 'phase3-domestic-outbound-gate', deploymentBlocker: true } const BASE = '/api/platform/v1/first-leg/outbound-dispatches' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function rejected(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } 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 } } } export async function run(context) { const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const inboundId = id(fixture?.inboundId) const containerBookingId = id(fixture?.containerBookingId) if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus) || fixture?.marker !== context.marker) { context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环', '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') return } const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(containerBookingId)}` const listResponse = await request(context, 'GET', `${BASE}?${selector}`) const aggregates = context.apiSuccess(MODULE, '按入库单和柜次查询出仓主聚合', listResponse, selector, OPTIONS) const aggregate = Array.isArray(aggregates) ? aggregates.find((item) => id(item.inboundId) === inboundId && id(item.containerBookingId) === containerBookingId) : null context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse, Boolean(aggregate?.orderId) && Boolean(aggregate?.tenantId) && Boolean(aggregate?.loadingPlanId) && id(aggregate?.inboundId) === inboundId && id(aggregate?.containerBookingId) === containerBookingId, `orderId=${id(aggregate?.orderId) || 'missing'}, tenantId=${id(aggregate?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}`, OPTIONS) if (!aggregate) return const orderResponse = await request(context, 'GET', `${BASE}?orderId=${encodeURIComponent(id(aggregate.orderId))}`) const orderAggregates = context.apiSuccess(MODULE, '按 orderId 查询全部出仓柜次', orderResponse, '', OPTIONS) context.assert(MODULE, 'orderId 查询包含本轮入库单柜次', orderResponse, Array.isArray(orderAggregates) && orderAggregates.some((item) => id(item.inboundId) === inboundId && id(item.containerBookingId) === containerBookingId), `records=${orderAggregates?.length ?? 'n/a'}`, OPTIONS) const detailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS) context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse, Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId && id(detail?.aggregate?.containerBookingId) === containerBookingId, `timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS) const gateResponse = await request(context, 'GET', `${BASE}/check-gate?${selector}`) const gate = context.apiSuccess(MODULE, '执行国内出仓统一门禁', gateResponse, '', OPTIONS) const gateItems = Array.isArray(gate?.items) ? gate.items : [] const expectedCodes = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED', 'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE'] context.assert(MODULE, '门禁逐项公开真实状态且不省略下游模块', gateResponse, expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean')), `codes=${gateItems.map((item) => item.code).join(',')}`, OPTIONS) const expectedVersion = Number(aggregate.version) const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker } const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128) if (!gate?.passed) { const blocked = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked), `HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS) for (const item of gateItems.filter((value) => !value.passed)) { context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code, `${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`, item.actual === 'MODULE_UNAVAILABLE' ? 'not-implemented' : 'cross-module-linkage') } return } const confirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, { 'Idempotency-Key': confirmKey }) const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS) context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse, confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && confirmed?.aggregate?.inboundStatus === 'WAITING_FOR_LOADING' && Number(confirmed?.aggregate?.version) === expectedVersion + 1, `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, { 'Idempotency-Key': confirmKey }) const replay = context.apiSuccess(MODULE, 'confirm-dispatch 幂等重放', replayResponse, '', OPTIONS) context.assert(MODULE, '确认重放保持同一终态和版本', replayResponse, replay?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && Number(replay?.aggregate?.version) === Number(confirmed?.aggregate?.version), `version=${replay?.aggregate?.version ?? 'missing'}`, OPTIONS) const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' }, { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, rejected(hashConflict), `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128) const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) const reversed = context.apiSuccess(MODULE, '撤销国内出仓恢复装柜完成', reverseResponse, '', OPTIONS) context.assert(MODULE, 'reverse-dispatch 恢复 READY_FOR_DISPATCH 与 LOADED', reverseResponse, reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) }