Files
baiyue-firstleg/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs
T
2026-09-16 11:06:33 +08:00

188 lines
12 KiB
JavaScript

const MODULE = 'phase5-terminal-drayage'
const SCENARIO = 'phase5-terminal-drayage-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const BASE = '/api/platform/v1/terminal-drayages'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
function localIso(value) { return value.toISOString().slice(0, 19) }
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
async function api(context, method, name, path, body, headers = {}) {
const response = await context.request(method, path, body, context.token, headers)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
function blocker(context, name, evidence, response = null) {
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
}
function completedTarget(context) {
const prepared = context.createdResources?.phase5TerminalDrayage?.prepared
const pda = context.createdResources?.phase4OverseasPda
if (prepared?.marker !== context.marker || pda?.marker !== context.marker
|| pda?.finalStatus !== 'COMPLETED') return null
const completed = (pda.completedContainerFacts || []).find((item) =>
id(item?.containerBookingId) === id(prepared.containerBookingId)
&& id(item?.containerNo) === id(prepared.containerNo))
return completed ? { ...prepared, pda, completed } : null
}
async function command(context, target, action, path, body) {
const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128)
const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`,
body, { 'Idempotency-Key': key })
if (!success(first.response)) return first
const replay = await api(context, 'POST', `目的港码头拖车${action}同键重放`,
`${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key })
context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response,
JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
const changed = path === 'empty-return' ? { ...body, remark: `${body.remark}-DIFFERENT` }
: { ...body, reason: `${body.reason}-DIFFERENT` }
const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`,
changed, context.token, { 'Idempotency-Key': key })
context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict,
conflict(hashConflict), trace(hashConflict), OPTIONS)
const afterConflict = await api(context, 'GET', `${action}冲突后回读聚合`, `${BASE}/${target.containerBookingId}`)
context.assert(MODULE, `${action}冲突后聚合零漂移`, afterConflict.response,
JSON.stringify(afterConflict.data) === JSON.stringify(first.data), trace(afterConflict.response), OPTIONS)
return first
}
export async function prepareTerminalDrayage(context, producer) {
const matrix = context.createdResources?.phase4InboundProducerFixtures
if (matrix?.marker !== context.marker || !producer || producer?.shippingReady !== true
|| !id(producer?.containerBookingId) || !id(producer?.containerNo)
|| !id(producer?.carrierScheduleQueryId) || !producer?.carrierLatestActualAt) {
blocker(context, '取得本轮PICKUP_READY货柜执行码头提柜前置',
`matrix=${matrix?.marker || 'missing'}, container=${id(producer?.containerNo) || 'missing'}; 禁止借历史货柜`)
return null
}
const resource = context.createdResources.phase5TerminalDrayage || {
marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null,
cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence',
}
context.createdResources.phase5TerminalDrayage = resource
const target = { marker: context.marker, producer, containerBookingId: id(producer.containerBookingId),
containerNo: id(producer.containerNo) }
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
const initial = await api(context, 'GET', '卸柜前读取目的港码头拖车初始聚合', path)
context.assert(MODULE, 'PICKUP_READY货柜允许选择车队', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === target.containerNo
&& Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'),
`status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=OVERSEAS&page=1&size=100')
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE' && item?.category === 'TRUCKING' && item?.truckingScope === 'OVERSEAS')
if (!fleet) {
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING + OVERSEAS公共供应商;脚本不临时创建主数据', suppliers.response)
return null
}
const selected = await command(context, target, '选择车队', 'fleet', {
expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id),
fleetSupplierName: fleet.name, reason: context.marker,
})
context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response,
id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name
&& selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS)
if (!success(selected.response)) return null
const latestActual = Date.parse(`${producer.carrierLatestActualAt}Z`)
const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000))
const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', {
expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker,
})
context.assert(MODULE, '预约提柜时间和版本持久化', scheduled.response,
scheduled.data?.pickupAppointmentAt === appointmentAt
&& Number(scheduled.data?.version) === Number(selected.data.version) + 1,
trace(scheduled.response), OPTIONS)
if (!success(scheduled.response)) return null
resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) }
resource.containerBookingId = target.containerBookingId
resource.containerNo = target.containerNo
return resource.prepared
}
export async function run(context) {
const resource = context.createdResources.phase5TerminalDrayage || {
marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null,
cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence',
}
context.createdResources.phase5TerminalDrayage = resource
const target = completedTarget(context)
if (!target) {
blocker(context, '取得本轮已提柜且已卸柜货柜',
`prepared=${resource.prepared?.marker || 'missing'}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
return
}
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
let initialResponse = null
let initialData = null
let pollRequests = 0
for (let attempt = 1; attempt <= 30; attempt += 1) {
pollRequests = attempt
initialResponse = await context.request('GET', path, undefined, context.token)
initialData = success(initialResponse) ? initialResponse.body?.data : null
if (initialData?.status === 'UNLOADED'
&& initialData?.allowedActions?.includes('RECORD_EMPTY_RETURN')) break
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const initial = {
response: initialResponse,
data: context.apiSuccess(MODULE, '卸柜后等待跨区事件并读取目的港码头拖车聚合', initialResponse,
`pollRequests=${pollRequests}; ${trace(initialResponse)}`, OPTIONS),
}
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
&& Number(initial.data?.version) === Number(target.scheduledVersion)
&& initial.data?.status === 'UNLOADED'
&& initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
if (!success(initial.response)) return
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
})
context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response,
returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt
&& Array.isArray(returned.data?.allowedActions) && returned.data.allowedActions.length === 0
&& returned.data?.emptyReturnWarning?.status === 'RESOLVED', trace(returned.response), OPTIONS)
if (!success(returned.response)) return
const list = await api(context, 'GET', '批量回读本轮码头拖车聚合',
`${BASE}?containerBookingIds=${encodeURIComponent(target.containerBookingId)}`)
context.assert(MODULE, '批量读取只命中本轮货柜终态', list.response,
Array.isArray(list.data) && list.data.length === 1
&& id(list.data[0]?.containerBookingId) === target.containerBookingId
&& list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS)
const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`)
const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action))
context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response,
['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action))
&& history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1),
`actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS)
context.assert(MODULE, '还柜备注写入不可变历史', history.response,
history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker),
trace(history.response), OPTIONS)
context.createdResources.phase5TerminalDrayage = {
...context.createdResources.phase5TerminalDrayage,
containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo,
finalStatus: 'EMPTY_RETURNED', version: Number(returned.data.version),
}
context.createdResources.phase4CarrierPickupReady = {
marker: context.marker, queryId: id(target.producer.carrierScheduleQueryId),
latestActualAt: target.producer.carrierLatestActualAt, ready: true,
}
}