Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs
T
ouyangxq 3d81b22850 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
2026-09-02 14:46:15 +08:00

193 lines
14 KiB
JavaScript

import { outboundAggregateFieldGaps } from './fulfillment-list-field-contract.mjs'
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.addStep(MODULE, '取得同 marker 的 LOADED 国内出仓前置', null, false,
`inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, finalStatus=${fixture?.finalStatus || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`,
OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环',
'本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture')
return
}
const selector = `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.containerBookingId) === containerBookingId) : null
const inboundBinding = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === inboundId)
const outboundFieldGaps = outboundAggregateFieldGaps(aggregate)
context.assert(MODULE, '主聚合串联订单、入库单、柜次、计划与客户隔离键', listResponse,
Boolean(inboundBinding?.orderId) && Boolean(inboundBinding?.tenantId) && Boolean(aggregate?.loadingPlanId)
&& id(aggregate?.containerBookingId) === containerBookingId,
`orderId=${id(inboundBinding?.orderId) || 'missing'}, tenantId=${id(inboundBinding?.tenantId) || 'missing'}, planId=${id(aggregate?.loadingPlanId) || 'missing'}, inbounds=${aggregate?.inbounds?.length ?? 'missing'}`, OPTIONS)
context.assert(MODULE, '国内出仓列表客户、订单入库、柜次计划、PDA订舱与门禁摘要完整', listResponse,
outboundFieldGaps.length === 0, `missing=${outboundFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!aggregate) return
const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`)
const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS)
context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse,
Array.isArray(detail?.timeline) && id(detail?.aggregate?.containerBookingId) === containerBookingId
&& detail?.aggregate?.inbounds?.some?.((item) => id(item?.inboundId) === inboundId),
`timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS)
const gateResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}/gate`)
const gate = context.apiSuccess(MODULE, '执行国内出仓统一门禁', gateResponse, '', OPTIONS)
const gateItems = Array.isArray(gate?.inbounds)
? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : []
const expectedCodes = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',
'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE']
context.assert(MODULE, '门禁逐项公开真实状态且不省略下游模块', gateResponse,
expectedCodes.every((code) => gateItems.some((item) => item.code === code && typeof item.passed === 'boolean'))
&& gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
`codes=${gateItems.map((item) => item.code).join(',')}`, OPTIONS)
context.assert(MODULE, '拖车与独立船期仅作聚合参考且不参与出仓门禁', gateResponse,
Object.hasOwn(aggregate, 'truckAppointmentStatus') && Object.hasOwn(aggregate, 'oceanBookingStatus')
&& gateItems.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
if (aggregate?.dispatchStatus === 'WAITING_FOR_LOADING') {
context.assert(MODULE, '整柜报关完成后系统已自动确认国内离仓', detailResponse,
aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
&& detail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
&& item.action === 'CONFIRM_DISPATCH'),
`dispatch=${aggregate.dispatchStatus}, inbounds=${aggregate?.inbounds?.length ?? 'missing'}`, OPTIONS)
context.createdResources.phase3OutboundDispatch = {
marker: context.marker, inboundId, containerBookingId,
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
confirmedStatus: aggregate.dispatchStatus, confirmedInboundStatus: 'LOADED',
confirmedVersion: Number(aggregate.version), finalStatus: aggregate.dispatchStatus,
finalInboundStatus: 'LOADED', finalVersion: Number(aggregate.version),
}
return
}
const expectedVersion = Number(aggregate.version)
const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128)
if (!gate?.passed) {
const blocked = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { expectedVersion, remark: context.marker },
{ 'Idempotency-Key': confirmKey })
context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked),
`HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS)
context.addStep(MODULE, '国内出仓六项硬门禁必须在同轮全部通过', gateResponse, false,
`failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`,
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 confirmBody = { expectedVersion, remark: context.marker }
let confirmed
if (aggregate.dispatchStatus === 'WAITING_FOR_LOADING') {
confirmed = detail
context.assert(MODULE, '整柜报关完成事件已自动确认国内出仓', detailResponse,
confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
&& confirmed?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
&& confirmed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
&& item.action === 'CONFIRM_DISPATCH'),
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
} else {
const confirmResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody,
{ 'Idempotency-Key': confirmKey })
confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS)
context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse,
confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
&& confirmed?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
&& Number(confirmed?.aggregate?.version) === expectedVersion + 1,
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbounds=${confirmed?.aggregate?.inbounds?.length ?? 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
const replayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody,
{ '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}/${encodeURIComponent(containerBookingId)}/confirm`, { ...confirmBody, remark: 'forged' },
{ 'Idempotency-Key': confirmKey })
context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict,
hashConflict.http === 200 && codeOf(hashConflict) === 'A0409',
`HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS)
}
if (!confirmed?.aggregate) return
const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128)
const reverseResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, {
expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
}, { 'Idempotency-Key': reverseKey })
context.addStep(MODULE, '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', reverseResponse,
reverseResponse.http === 200 && codeOf(reverseResponse) === 'A0409'
&& String(reverseResponse.body?.message || '').includes('DEPARTED_WAREHOUSE')
&& String(reverseResponse.body?.message || '').includes('禁止撤销'),
`HTTP=${reverseResponse.http}, code=${codeOf(reverseResponse)}, message=${reverseResponse.body?.message || 'missing'}`, OPTIONS)
const reverseReplayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/reverse`, {
expectedVersion: Number(confirmed.aggregate.version), remark: context.marker,
}, { 'Idempotency-Key': reverseKey })
context.addStep(MODULE, '禁止撤销边界重复请求仍精确返回A0409', reverseReplayResponse,
reverseReplayResponse.http === 200 && codeOf(reverseReplayResponse) === 'A0409'
&& String(reverseReplayResponse.body?.message || '').includes('DEPARTED_WAREHOUSE'),
`HTTP=${reverseReplayResponse.http}, code=${codeOf(reverseReplayResponse)}`, OPTIONS)
const finalDetailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`)
const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS)
context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse,
finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
&& finalDetail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
&& Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version)
&& finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH'
&& item.action === 'CONFIRM_DISPATCH').length === 1
&& !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
&& item.action === 'REVERSE_DISPATCH'),
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
if (confirmed?.aggregate) {
context.createdResources.phase3OutboundDispatch = {
marker: context.marker, inboundId, containerBookingId,
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
confirmedStatus: confirmed.aggregate.dispatchStatus,
confirmedInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
confirmedVersion: Number(confirmed.aggregate.version),
finalStatus: confirmed.aggregate.dispatchStatus,
finalInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
finalVersion: Number(confirmed.aggregate.version),
}
}
}