Files
baiyue-firstleg/scripts/admin-test-env-modules/truck-appointments.mjs
T

114 lines
7.8 KiB
JavaScript

import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/truck-appointments'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function failure(response) { return 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 function inspectOceanBookingIdentity(marker, resource, detail = null) {
if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' }
if (resource.marker !== marker) return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` }
const booking = detail?.oceanBooking || null
if (booking && id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' }
const identity = { oceanBookingId: id(resource.id), warehouseId: id(resource.warehouseId || booking?.warehouseId) }
const missingFields = Object.entries(identity).filter(([, value]) => !value).map(([key]) => key)
return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields }
: { identity, reason: null, missingFields: [] }
}
export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) {
const supplier = context.createdResources?.truckSupplier || {}
return {
outboundOrderId: null, oceanBookingId: identity.oceanBookingId,
containerBookingId: identity.containerBookingId, loadingPlanId: identity.loadingPlanId || null,
warehouseId: identity.warehouseId || null, carrierId: id(supplier.supplierId) || null,
carrierName: supplier.supplierName || null, billOfLadingNo: identity.billOfLadingNo || null,
fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005',
vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人',
storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`,
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0,
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides,
}
}
function currentIdentity(context) {
const fixture = [context.createdResources?.phase3ContainerLoading, context.createdResources?.phase3Loading]
.find((item) => item?.marker === context.marker && id(item.oceanBookingId) && id(item.containerBookingId))
if (!fixture) return null
return { oceanBookingId: id(fixture.oceanBookingId), containerBookingId: id(fixture.containerBookingId),
loadingPlanId: id(fixture.loadingPlanId) || null, warehouseId: id(fixture.warehouseId) || null,
billOfLadingNo: fixture.billOfLadingNo || null }
}
export async function run(context) {
const identity = currentIdentity(context)
const supplier = context.createdResources?.truckSupplier
context.assert(MODULE, '本轮拖车前置仅依赖已放舱订舱、真实柜和拖车供应商', null,
Boolean(identity?.oceanBookingId && identity?.containerBookingId && supplier?.supplierId),
`oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}, carrierId=${id(supplier?.supplierId) || 'missing'}`, OPTIONS)
if (!identity?.oceanBookingId || !identity?.containerBookingId || !supplier?.supplierId) return
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10)
const base = truckAppointmentPayload(context, suffix, identity)
for (const [label, payload] of [
['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }],
['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }],
['缺少拖车供应商拒绝创建', { ...base, carrierId: null }],
['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }],
]) {
const response = await request(context, 'POST', BASE, payload)
context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS)
}
const createdResponse = await request(context, 'POST', BASE, base)
const appointment = context.apiSuccess(MODULE, '创建订舱真实柜拖车预约', createdResponse, '', OPTIONS)
const appointmentId = id(appointment?.id)
context.assert(MODULE, '响应不含客户字段且冻结订舱柜号关系', createdResponse,
Boolean(appointmentId) && appointment?.status === 'DRAFT' && !Object.hasOwn(appointment, 'customerId')
&& !Object.hasOwn(appointment, 'customerName') && id(appointment?.oceanBookingId) === identity.oceanBookingId
&& id(appointment?.containerBookingId) === identity.containerBookingId,
`appointmentId=${appointmentId || 'missing'}`, OPTIONS)
if (!appointmentId) return
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED' }
const path = `${BASE}/${appointmentId}`
let version = Number(appointment.version)
const submitKey = `${context.marker}-submit`
const submitBody = { expectedVersion: version, reason: '并发幂等提交' }
const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS)
const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS)
context.assert(MODULE, '提交幂等重放零漂移', replay,
id(firstData?.id) === id(replayData?.id) && Number(firstData?.version) === Number(replayData?.version),
`firstVersion=${firstData?.version}, replayVersion=${replayData?.version}`, OPTIONS)
version = Number(firstData?.version)
const conflict = await request(context, 'POST', `${path}/submit`, { expectedVersion: version, reason: '异载荷' },
{ 'Idempotency-Key': submitKey })
context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS)
const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: version, reason: '派车' },
{ 'Idempotency-Key': `${context.marker}-dispatch` })
const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS)
context.assert(MODULE, '派车状态与稳定字段完整', dispatch,
dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0,
`status=${dispatched?.status || 'missing'}`, OPTIONS)
}