Files
baiyue-firstleg/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs
T
2026-09-14 20:03:22 +08:00

259 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}
}
async function verifyTruckDeparturePayables(context, aggregate) {
const appointmentId = id(aggregate?.truckAppointmentId)
if (!appointmentId) return
const truckResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}`)
const truck = context.apiSuccess(MODULE, '回查国内离仓关联拖车', truckResponse, '', OPTIONS)
if (!truck) return
const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${id(truck.oceanBookingId)}`)
const booking = context.apiSuccess(MODULE, '核对拖车订舱全部要求货柜', bookingResponse, '', OPTIONS)
if (!booking) return
const expectedCount = (booking.containerRequirements || []).reduce((sum, item) => sum + Number(item.quantity), 0)
const departed = []
for (const container of booking.containers || []) {
const response = await request(context, 'GET', `${BASE}/${id(container.containerBookingId)}`)
const detail = context.apiSuccess(MODULE, '回查拖车关联货柜离仓事实', response, '', OPTIONS)
if (detail?.aggregate) departed.push(detail.aggregate)
}
const ready = expectedCount > 0 && departed.length === expectedCount
&& departed.every(item => item.dispatchStatus === 'WAITING_FOR_LOADING')
context.assert(MODULE, '全部要求货柜离仓才自动完成拖车', truckResponse,
ready ? truck.status === 'COMPLETED' : truck.status !== 'COMPLETED',
`ready=${ready}, status=${truck.status}`, OPTIONS)
if (!ready || truck.status !== 'COMPLETED') return
const historyResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}/history`)
const history = context.apiSuccess(MODULE, '回查拖车自动完成历史', historyResponse, '', OPTIONS)
context.assert(MODULE, '拖车自动完成仅记录一次', historyResponse,
Array.isArray(history) && history.filter(item => item.action === 'AUTO_COMPLETE').length === 1, '', OPTIONS)
const fees = new Map()
for (const container of departed) {
const customers = [...new Set(container.inbounds.map(item => id(item.customerId)))]
for (const customer of customers) {
const response = await request(context, 'GET', `/api/platform/v1/loading-plans/${id(container.loadingPlanId)}/fee-details?accountId=${customer}`)
const details = context.apiSuccess(MODULE, '回查拖车生成的柜次应付', response, '', OPTIONS)
for (const fee of Array.isArray(details) ? details : []) {
if (fee.direction === 'PAYABLE' && String(fee.remark || '').includes(`国内拖车单 ${truck.appointmentNo};`)) fees.set(id(fee.id), fee)
}
}
}
const totalCents = [...fees.values()].reduce((sum, fee) => sum + Math.round(Number(fee.amount) * 100), 0)
context.assert(MODULE, '拖车应付分摊守恒且自动审核', null,
totalCents === Math.round(Number(truck.payableAmount || 0) * 100)
&& [...fees.values()].every(fee => fee.currency === 'CNY' && fee.reviewStatus === 'APPROVED'
&& fee.settlementMode === 'ON_ACCOUNT' && id(fee.supplierId) === id(truck.carrierId)),
`sourceCount=${fees.size}, totalCents=${totalCents}`, OPTIONS)
const imported = new Map()
for (let attempt = 0; attempt < 12 && imported.size < fees.size; attempt++) {
for (const customer of new Set([...fees.values()].map(fee => id(fee.tenantId)))) {
for (let page = 1; ; page++) {
const response = await request(context, 'GET', `/api/platform/finance/v1/fee-sources?customerId=${customer}&direction=PAYABLE&page=${page}&pageSize=200`)
const rows = context.apiSuccess(MODULE, '回查拖车应付财务入池', response, '', OPTIONS)
if (!Array.isArray(rows)) break
rows.filter(row => row.sourceSystem === 'FIRSTLEG' && row.sourceType === 'CONTAINER_FEE'
&& fees.has(id(row.sourceFeeId))).forEach(row => imported.set(id(row.sourceFeeId), row))
if (page * 200 >= Number(response.body?.page?.total || 0)) break
}
}
if (imported.size < fees.size) await new Promise(resolve => setTimeout(resolve, 2000))
}
context.assert(MODULE, '拖车费用真实流转到财务应付来源', null,
imported.size === fees.size && [...imported.values()].every(row => row.currency === 'CNY'
&& row.direction === 'PAYABLE' && row.reviewStatus === 'APPROVED'
&& Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceFeeId)).amount) * 100)),
`expected=${fees.size}, imported=${imported.size}`, OPTIONS)
}
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') {
await verifyTruckDeparturePayables(context, aggregate)
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) {
await verifyTruckDeparturePayables(context, 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),
}
}
}