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' const SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function missing(response) { return response?.http === 404 || response?.http === 405 || response?.http === 503 } function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } function data(response) { return codeOf(response) === '200' ? response.body.data : 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 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 } } } async function uploadAttachment(context, appointmentId) { const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` const form = new FormData() const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck.png`) try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form, 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: 'POST', 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: 'POST', 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: `订舱详情资源归属不一致:expected=${id(resource.id)}, actual=${id(booking.id) || 'missing'}` } } const fields = ['customerId', 'warehouseId', 'customerName'] for (const field of fields) { const published = id(resource[field]) const queried = id(booking?.[field]) if (published && queried && published !== queried) { return { identity: null, reason: `订舱身份字段冲突:field=${field}, published=${published}, detail=${queried}` } } } const identity = { oceanBookingId: id(resource.id), customerId: id(resource.customerId || booking?.customerId), warehouseId: id(resource.warehouseId || booking?.warehouseId), customerName: id(resource.customerName || booking?.customerName), } const missingFields = fields.filter((field) => !identity[field]) return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields } : { identity, reason: null, missingFields: [] } } export function selectIndependentTruckCustomer(context) { const candidates = [ context.createdResources?.orders, context.createdResources?.merchantQualificationFixture, context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading, ].filter(Boolean) const source = candidates.find((item) => item.marker === context.marker && id(item.tenantId)) return source ? { customerId: id(source.tenantId), customerName: source.customerName || null } : null } export function truckAppointmentPayload(context, suffix, identity, version = 0, associations = {}) { const supplier = context.createdResources?.truckSupplier || {} const supplierId = id(supplier.supplierId) const supplierName = id(supplier.supplierName) return { outboundOrderId: null, oceanBookingId: associations.oceanBookingId || null, containerBookingId: associations.containerBookingId || null, loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null, customerId: identity.customerId, customerName: identity.customerName || null, carrierId: supplierId || null, carrierName: supplierName || null, billOfLadingNo: Object.hasOwn(associations, 'billOfLadingNo') ? associations.billOfLadingNo : `BL-TRUCK-${suffix}`.slice(0, 128), fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-09-02T08:00:00', transitMinutes: 120, payableAmount: 100, receivableAmount: 150, currency: 'RMB', remark: context.marker, expectedVersion: version, } } async function ensureTruckSupplier(context, suffix) { const response = await request(context, 'POST', SUPPLIER_BASE, { name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING', region: '深圳', legalRepresentative: '验收法人', contactName: '拖车联系人', phone: '13800000000', email: 'trucking-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK${suffix}`.slice(0, 64), licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, }) const supplierValue = data(response) const supplierId = id(typeof supplierValue === 'object' ? supplierValue?.id : supplierValue) let supplier = typeof supplierValue === 'object' ? supplierValue : null if (supplierId) { const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) supplier = data(detailResponse) } context.addStep(MODULE, '创建本轮有效 ACTIVE TRUCKING 拖车供应商', response, Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name), `supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS) if (supplierId) context.createdResources.truckSupplier = { marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null, category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true, } return supplierId && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' ? context.createdResources.truckSupplier : null } export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) if (missing(list) || list.http === 0) { context.addStep(MODULE, '拖车预约服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`, OPTIONS); return } const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS) context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) { const response = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const result = data(response) context.addStep(MODULE, `拖车预约 ${status} 筛选`, response, response.http === 200 && codeOf(response) === '200' && Array.isArray(result?.records) && result.records.every((item) => item.status === status), `HTTP=${response.http}, code=${codeOf(response)}, records=${result?.records?.length ?? 'n/a'}`, OPTIONS) } const customerIdentity = selectIndependentTruckCustomer(context) if (!customerIdentity) { context.addStep(MODULE, '取得本轮独立拖车客户身份', null, false, '缺少同 marker 的 customerId;未借用历史客户或订舱', OPTIONS) return } const supplier = await ensureTruckSupplier(context, suffix) if (!supplier?.supplierId || !supplier?.supplierName) return const basePayload = truckAppointmentPayload(context, suffix, customerIdentity) for (const [name, payload] of [ ['缺少 customerId 拒绝创建独立拖车', { ...basePayload, customerId: null, customerName: null }], ['缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', { ...basePayload, carrierId: null, carrierName: null }], ['缺少 plannedLoadingAt 拒绝创建独立拖车', { ...basePayload, plannedLoadingAt: null }], ]) { const rejected = await request(context, 'POST', BASE, payload) context.addStep(MODULE, name, rejected, failure(rejected), `HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS) } const missingBillCreate = await request(context, 'POST', BASE, truckAppointmentPayload(context, `${suffix}B`, customerIdentity, 0, { billOfLadingNo: null })) const missingBill = data(missingBillCreate) if (missingBill?.id) { const missingBillSubmit = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/submit`, { expectedVersion: Number(missingBill.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-truck-no-bill-submit`.slice(0, 128) }) const submitted = data(missingBillSubmit) const rejectedDispatch = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/dispatch`, { expectedVersion: Number(submitted?.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-truck-no-bill-dispatch`.slice(0, 128) }) context.addStep(MODULE, '拖车 DRAFT 可无提单但 DISPATCH 缺提单号与文件明确拒绝', rejectedDispatch, failure(rejectedDispatch), `HTTP=${rejectedDispatch.http}, code=${codeOf(rejectedDispatch)}`, OPTIONS) } const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading] .find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId)) if (linkedFlow) { const invalidPlanId = (BigInt(id(linkedFlow.loadingPlanId)) + 1n).toString() const mismatchedPlan = await request(context, 'POST', BASE, truckAppointmentPayload(context, `${suffix}P`, customerIdentity, 0, { containerBookingId: id(linkedFlow.containerBookingId), loadingPlanId: invalidPlanId, warehouseId: id(linkedFlow.warehouseId), oceanBookingId: id(linkedFlow.oceanBookingId) || null, })) context.addStep(MODULE, 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', mismatchedPlan, failure(mismatchedPlan), `containerBookingId=${id(linkedFlow.containerBookingId)}, loadingPlanId=${invalidPlanId}, HTTP=${mismatchedPlan.http}, code=${codeOf(mismatchedPlan)}`, OPTIONS) if (id(linkedFlow.warehouseId)) { const mismatchedWarehouseId = (BigInt(id(linkedFlow.warehouseId)) + 1n).toString() const mismatchedWarehouse = await request(context, 'POST', BASE, truckAppointmentPayload(context, `${suffix}W`, customerIdentity, 0, { containerBookingId: id(linkedFlow.containerBookingId), warehouseId: mismatchedWarehouseId, })) context.addStep(MODULE, 'warehouseId 与可选关联柜次不一致时零写入拒绝', mismatchedWarehouse, failure(mismatchedWarehouse), `containerBookingId=${id(linkedFlow.containerBookingId)}, warehouseId=${mismatchedWarehouseId}, HTTP=${mismatchedWarehouse.http}, code=${codeOf(mismatchedWarehouse)}`, OPTIONS) } const unrelatedBooking = context.createdResources?.oceanBookingUnassignedCustomer if (unrelatedBooking?.marker === context.marker && id(unrelatedBooking.id)) { const mismatchedAllocation = await request(context, 'POST', BASE, truckAppointmentPayload(context, `${suffix}A`, customerIdentity, 0, { oceanBookingId: id(unrelatedBooking.id), containerBookingId: id(linkedFlow.containerBookingId), })) context.addStep(MODULE, '订舱与柜次无 allocation 关系时零写入拒绝', mismatchedAllocation, failure(mismatchedAllocation), `oceanBookingId=${id(unrelatedBooking.id)}, containerBookingId=${id(linkedFlow.containerBookingId)}, HTTP=${mismatchedAllocation.http}, code=${codeOf(mismatchedAllocation)}`, OPTIONS) } } const createResponse = await request(context, 'POST', BASE, basePayload); const appointment = context.apiSuccess(MODULE, '创建无订舱柜次计划关联的独立拖车预约', createResponse, '', OPTIONS) const appointmentId = id(appointment?.id) context.assert(MODULE, '独立拖车仅冻结客户承运商与自身业务字段', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.oceanBookingId == null && appointment?.containerBookingId == null && appointment?.loadingPlanId == null && id(appointment?.customerId) === customerIdentity.customerId && id(appointment?.carrierId) === id(supplier.supplierId) && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}, oceanBookingId=${id(appointment?.oceanBookingId) || 'null'}, containerBookingId=${id(appointment?.containerBookingId) || 'null'}`, OPTIONS) if (!appointmentId) return context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'COMPLETED' } const path = `${BASE}/${appointmentId}` const upload = await uploadAttachment(context, appointmentId); const attachment = context.apiSuccess(MODULE, '上传本轮拖车预约图片附件', upload, `fileName=${context.marker}-truck.png`, OPTIONS) const attachmentId = id(attachment?.id) context.assert(MODULE, '提单附件归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'BILL_OF_LADING' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS) const attachments = await request(context, 'GET', `${path}/attachments`); const attachmentList = context.apiSuccess(MODULE, '查询拖车预约附件列表', attachments, '', OPTIONS) context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS) const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'BILL_OF_LADING', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' }) context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) context.assert(MODULE, '派车前保留有效 BILL_OF_LADING 提单附件', attachments, Boolean(attachmentId) && attachmentList.some((item) => id(item.id) === attachmentId && item.attachmentType === 'BILL_OF_LADING'), `attachmentId=${attachmentId || 'missing'}`, OPTIONS) let version = Number(appointment.version) const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, 0), vehicleNo: `STALE-${suffix}` }) context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS) const after = await request(context, 'GET', path); const afterData = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS) context.assert(MODULE, '拖车预约冲突未部分更新', after, afterData?.vehicleNo === edited?.vehicleNo, `vehicleNo=${afterData?.vehicleNo || 'missing'}`, OPTIONS); version = Number(afterData?.version) const action = async (name, endpoint, expectedStatus) => { const response = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: version, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const result = context.apiSuccess(MODULE, name, response, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, response, result?.status === expectedStatus, `status=${result?.status || 'missing'}`, OPTIONS); version = Number(result?.version) } for (const [name, endpoint, status] of [['提交预约','submit','PENDING_DISPATCH'],['派车','dispatch','DISPATCHED'],['开始提货','pickup','PICKUP_IN_PROGRESS'],['到仓','arrive','ARRIVED_WAREHOUSE'],['开始装货','load','LOADING'],['离场','depart','DEPARTED'],['完成','complete','COMPLETED']]) await action(name, endpoint, status) const final = await request(context, 'GET', path); const finalData = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS) const appointmentFieldGaps = truckAppointmentFieldGaps(finalData) context.assert(MODULE, '拖车列表客户、仓库、订舱柜次、供应商与关键时间字段完整', final, appointmentFieldGaps.length === 0, `missing=${appointmentFieldGaps.join(',') || 'none'}`, OPTIONS) context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, finalData?.status === 'COMPLETED' && finalData?.payableAmount != null && finalData?.receivableAmount != null, `status=${finalData?.status || 'missing'}`, OPTIONS) context.assert(MODULE, '独立拖车完成自身状态机后仍不产生履约关联', final, finalData?.oceanBookingId == null && finalData?.containerBookingId == null && finalData?.loadingPlanId == null, `oceanBookingId=${id(finalData?.oceanBookingId) || 'null'}, containerBookingId=${id(finalData?.containerBookingId) || 'null'}, loadingPlanId=${id(finalData?.loadingPlanId) || 'null'}`, OPTIONS) const history = await request(context, 'GET', `${path}/history`); const histories = context.apiSuccess(MODULE, '查询拖车预约状态历史', history, '', OPTIONS) const actions = Array.isArray(histories) ? histories.map((item) => item.action) : [] context.assert(MODULE, '拖车状态历史动作、版本和审计操作者完整', history, ['SUBMIT','DISPATCH','PICKUP','ARRIVE','LOAD','DEPART','COMPLETE'].every((item) => actions.includes(item)) && histories.every((item) => Number.isFinite(Number(item.revision)) && Boolean(item.operatorName) && Boolean(item.createdAt)), `actions=${actions.join(',')}`, OPTIONS) }