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