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, } }