155 lines
10 KiB
JavaScript
155 lines
10 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 fixture(context) {
|
|
const matrix = context.createdResources?.phase4InboundProducerFixtures
|
|
const pda = context.createdResources?.phase4OverseasPda
|
|
if (matrix?.marker !== context.marker || pda?.marker !== context.marker
|
|
|| pda?.finalStatus !== 'COMPLETED') return null
|
|
const producers = Array.isArray(matrix.containers) ? matrix.containers : []
|
|
for (const completed of pda.completedContainerFacts || []) {
|
|
const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId)
|
|
&& id(item?.containerNo) === id(completed?.containerNo)
|
|
&& item?.shippingReady === true && id(item?.carrierScheduleQueryId)
|
|
&& item?.carrierLatestActualAt)
|
|
if (producer) return {
|
|
matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId),
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
async function command(context, target, action, path, body) {
|
|
const key = `${context.marker}-terminal-${action}`.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 = ['pickup', 'empty-return'].includes(path) ? { ...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 run(context) {
|
|
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',
|
|
}
|
|
const target = fixture(context)
|
|
if (!target) {
|
|
blocker(context, '取得本轮PICKUP_READY且已卸柜货柜',
|
|
`matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
|
|
return
|
|
}
|
|
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
|
|
const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path)
|
|
context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response,
|
|
id(initial.data?.containerBookingId) === target.containerBookingId
|
|
&& id(initial.data?.containerNo) === id(target.completed.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)) return
|
|
|
|
const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商',
|
|
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100')
|
|
const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE')
|
|
if (!fleet) {
|
|
blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response)
|
|
return
|
|
}
|
|
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
|
|
|
|
const latestActual = Date.parse(`${target.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
|
|
|
|
const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000))
|
|
const picked = await command(context, target, '确认实际提柜', 'pickup', {
|
|
expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker,
|
|
})
|
|
context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response,
|
|
picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt
|
|
&& picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS)
|
|
if (!success(picked.response)) return
|
|
|
|
const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`)
|
|
const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
|
|
const returnedAt = localIso(new Date(returnBase + 60 * 60_000))
|
|
const returned = await command(context, target, '确认实际还柜', 'empty-return', {
|
|
expectedVersion: Number(picked.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_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,
|
|
['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) =>
|
|
history.data?.some((item) => item.action === action && 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,
|
|
}
|
|
}
|