import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } const TRUCKS = '/api/platform/v1/truck-appointments' const BOOKINGS = '/api/platform/v1/ocean-bookings' const CONTAINERS = '/api/platform/v1/containers' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } export function minimalPdfBytes(label = 'truck-appointment-fixture') { const safeLabel = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64) return new TextEncoder().encode(`%PDF-1.4 % ${safeLabel} 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj trailer << /Root 1 0 R >> %%EOF `) } export function platformWarehouseIdentity(item) { return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' } } export function activeCustomerIdentity(item) { const customerId = id(item?.id || item?.tenantId) const customerName = String(item?.tenantName || item?.customerName || item?.name || '').trim() return customerId && customerName ? { customerId, customerName } : null } 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 parsed = null try { parsed = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null, requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.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 || resource.marker !== marker) return { identity: null, reason: '订舱不属于本轮' } const booking = detail?.oceanBooking || resource if (id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' } return { identity: { oceanBookingId: id(resource.id), warehouseId: id(booking.warehouseId) }, reason: null, missingFields: [] } } export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) { const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {} return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId, loadingPlanId: null, warehouseId: identity.warehouseId, carrierId: id(supplier.supplierId), carrierName: supplier.supplierName, billOfLadingNo: identity.billOfLadingNo, 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, currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides } } async function activeSupplier(context, category, suffix) { const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE&page=1&size=100`) const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE') if (existing) return { supplierId: id(existing.id), supplierName: existing.name } const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', { name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [], region: '深圳', contactName: '验收联系人', phone: '13800000000', status: 'ACTIVE', remark: context.marker, }) const value = data(created); const supplierId = id(value?.id || value) context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS) return supplierId ? { supplierId, supplierName: value?.name || `${category}-验收-${suffix}` } : null } async function bookingMaster(context, suffix) { const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId || context.context?.customerId || context.customerId) const customersResponse = await request(context, 'GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') const candidates = records(data(customersResponse)) const customer = activeCustomerIdentity(candidates.find((item) => fixtureCustomerId && id(item?.id || item?.tenantId) === fixtureCustomerId) || candidates[0]) if (!customer) { context.addStep(MODULE, '通过正式平台客户查询解析 ACTIVE 订舱客户', customersResponse, false, `preferredCustomerId=${fixtureCustomerId || 'missing'}`, OPTIONS) return null } let lastWarehousesResponse = null for (let page = 1; page <= 100; page++) { const warehousesResponse = await request(context, 'GET', `/api/platform/v1/warehouse-admin?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=${page}&size=100`) lastWarehousesResponse = warehousesResponse const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage) for (const item of warehouseRows) { const { warehouseId } = platformWarehouseIdentity(item) if (!warehouseId) continue const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) const channelRows = records(data(channelsResponse)); const channel = channelRows[0] if (channel?.channelCode) { context.addStep(MODULE, '完整分页平台仓并解析有效海运渠道', channelsResponse, true, `warehousePage=${page}, warehouseRecords=${warehouseRows.length}, channelRecords=${channelRows.length}, warehouseId=${warehouseId}`, OPTIONS) return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName } } } const total = Number(warehousePage?.total ?? warehouseRows.length) if (warehouseRows.length === 0 || page * 100 >= total) break } context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', lastWarehousesResponse, false, `platformTenantId=0, customerId=${customer.customerId}`, OPTIONS) return null } function isoContainerNo(marker) { const values = {}; let value = 10 for (let code = 65; code <= 90; code++) { while (value % 11 === 0) value++; values[String.fromCharCode(code)] = value++ } const serial = String([...marker].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % 1_000_000).padStart(6, '0') const base = `TSTU${serial}`; let sum = 0 ;[...base].forEach((char, index) => { sum += (values[char] ?? Number(char)) * (2 ** index) }) return `${base}${(sum % 11) % 10}` } async function fixture(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) || 'TRUCK' const master = await bookingMaster(context, suffix); if (!master?.customerId) return null const bookingAgent = await activeSupplier(context, 'BOOKING', suffix) const truckSupplier = await activeSupplier(context, 'TRUCKING', suffix) const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) if (!bookingAgent || !truckSupplier || !carrier?.id) return null const draft = oceanBookingDraft(context, suffix, master, { bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], }) const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS) const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version)) form.append('documentType', 'BILL_OF_LADING') form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form, context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) }) const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS) const releaseResponse = await request(context, 'POST', `${BOOKINGS}/${bookingId}/release`, { expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) }) const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS) const requirement = released?.containerRequirements?.[0]; if (released?.oceanBooking?.status !== 'RELEASED' || !requirement?.id) return null const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode: master.channelCode, warehouseId: master.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) const simulation = context.apiSuccess(MODULE, '创建自包含模拟柜', simulationResponse, '', OPTIONS) const containerNo = isoContainerNo(context.marker); if (!simulation?.id) return null const linkResponse = await request(context, 'POST', `${CONTAINERS}/${simulation.id}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version), }) const linked = context.apiSuccess(MODULE, '绑定本轮真实 ISO 柜号', linkResponse, '', OPTIONS) if (linked?.containerNo !== containerNo) return null return { suffix, oceanBookingId: bookingId, containerBookingId: id(linked.id), warehouseId: master.warehouseId, billOfLadingNo: released.oceanBooking.billOfLadingNo, truckSupplier, bookingVersion: released.oceanBooking.version } } export async function run(context) { const identity = await fixture(context) context.assert(MODULE, '拖车模块自包含前置链完整', null, Boolean(identity?.oceanBookingId && identity?.containerBookingId), `oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}`, OPTIONS) if (!identity) return context.createdResources.truckSupplier = identity.truckSupplier const base = truckAppointmentPayload(context, identity.suffix, identity) context.assert(MODULE, '拖车请求不含客户字段', null, !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), Object.keys(base).join(','), OPTIONS) for (const [label, payload] of [ ['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }], ['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }], ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], ['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }], ]) { const response = await request(context, 'POST', TRUCKS, payload) context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) } const createdResponse = await request(context, 'POST', TRUCKS, base) const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS) const appointmentId = id(appointment?.id); if (!appointmentId) return context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse, appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0 && !Object.hasOwn(appointment, 'receivableAmount') && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) const path = `${TRUCKS}/${appointmentId}` const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER') attachmentForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER`)], { type: 'application/pdf' }), `dispatch-order-${identity.suffix}.pdf`) const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, attachmentForm, context.token) const uploadedAttachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) const uploadedAttachmentId = id(uploadedAttachment?.id); const uploadedFileId = id(uploadedAttachment?.fileId) context.assert(MODULE, '拖车附件类型仅使用运营附件而非订舱提单', uploadAttachmentResponse, Boolean(uploadedAttachmentId && uploadedFileId) && uploadedAttachment?.attachmentType === 'DISPATCH_ORDER', `attachmentId=${uploadedAttachmentId || 'missing'}, fileId=${uploadedFileId || 'missing'}`, OPTIONS) const deleteUploadedResponse = await request(context, 'DELETE', `${path}/attachments/${uploadedAttachmentId}`) context.apiSuccess(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, '', OPTIONS) const referenceResponse = await request(context, 'POST', `${path}/attachments`, { fileId: uploadedFileId, attachmentType: 'DISPATCH_ORDER' }) const referencedAttachment = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referenceResponse, '', OPTIONS) const referencedAttachmentId = id(referencedAttachment?.id) const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) const attachmentList = context.apiSuccess(MODULE, '查询拖车运营附件列表', attachmentListResponse, '', OPTIONS) context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', attachmentListResponse, Array.isArray(attachmentList) && attachmentList.some((item) => id(item?.id) === referencedAttachmentId && item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${referencedAttachmentId || 'missing'}`, OPTIONS) const deleteReferencedResponse = await request(context, 'DELETE', `${path}/attachments/${referencedAttachmentId}`) context.apiSuccess(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, '', OPTIONS) const usdResponse = await request(context, 'PUT', path, { ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) }) const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS) context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse, usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34 && !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS) const duplicate = await request(context, 'POST', TRUCKS, base) context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) const submitKey = `${context.marker}-truck-submit`.slice(0, 128) const submitBody = { expectedVersion: Number(usdAppointment?.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, Number(firstData?.version) === Number(replayData?.version), '', OPTIONS) const conflict = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, { 'Idempotency-Key': submitKey }) context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS) const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: Number(firstData?.version), reason: '派车' }, { 'Idempotency-Key': `${context.marker}-truck-dispatch`.slice(0, 128) }) const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS) context.assert(MODULE, '派车命令返回状态身份与版本正确', dispatch, dispatched?.status === 'DISPATCHED' && id(dispatched?.id) === appointmentId && Number(dispatched?.version) > Number(firstData?.version), `id=${id(dispatched?.id) || 'missing'}, status=${dispatched?.status || 'missing'}, version=${dispatched?.version ?? 'missing'}`, OPTIONS) const detailResponse = await request(context, 'GET', path); const detail = data(detailResponse) context.apiSuccess(MODULE, '派车后回查拖车完整详情', detailResponse, '', OPTIONS) const detailGaps = truckAppointmentFieldGaps(detail) context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse, detail?.status === 'DISPATCHED' && detailGaps.length === 0 && !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName') && !Object.hasOwn(detail || {}, 'receivableAmount') && detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34 && id(detail?.oceanBookingId) === identity.oceanBookingId && id(detail?.containerBookingId) === identity.containerBookingId, `missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } }