114 lines
7.8 KiB
JavaScript
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)
|
|
}
|