diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index fe8d602ea..7b5ef104d 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -49,11 +49,16 @@ test('backend freezes released booking real container bill file and carrier rule } }) -test('HTTP regression covers no-customer response and submit idempotency matrix', () => { +test('HTTP regression is self-contained and covers no-customer and idempotency matrix', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移', - '同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label)) - assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/) + for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', + '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', + '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', + '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建']) assert.match(source, new RegExp(label)) + assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) + const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) + assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) }) test('schema owns platform attachments and one active appointment per real container', () => { diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 5b8a47660..49d7d8368 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,113 +1,174 @@ +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 BASE = '/api/platform/v1/truck-appointments' +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 failure(response) { return response?.http === 200 && codeOf(response) !== '200' } +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 || [] } 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}`, + 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') } + 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) 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: [] } + 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 = 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', + 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, receivableAmount: 0, - currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides, - } + 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 } +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 tenantId = id(context.createdResources?.merchantQualificationFixture?.tenantId) + const customerName = String(context.options?.customerName || `全量验收下单客户-${suffix}`).trim() + const warehousesResponse = await request(context, 'GET', '/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=1&size=100') + for (const item of records(data(warehousesResponse))) { + const warehouseId = id(item?.id || item?.warehouse?.id) + if (!warehouseId) continue + const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) + const channel = (data(channelsResponse) || [])[0] + if (channel?.channelCode) return { customerId: tenantId, customerName, warehouseId, + channelCode: channel.channelCode, channelName: channel.channelName } + } + context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', warehousesResponse, false, + `tenantId=${tenantId || 'missing'}`, 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([`${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 = 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) + 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 [ - ['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }], - ['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }], - ['缺少拖车供应商拒绝创建', { ...base, carrierId: null }], - ['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }], + ['错误订舱拒绝创建', { ...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() }], ]) { - const response = await request(context, 'POST', BASE, payload) - context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS) + 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', 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 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, '拖车响应不含客户字段', createdResponse, + !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + const duplicate = await request(context, 'POST', TRUCKS, base) + context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) + const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128) + const submitBody = { expectedVersion: Number(appointment.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.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: version, reason: '派车' }, - { 'Idempotency-Key': `${context.marker}-dispatch` }) + 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, + context.assert(MODULE, '派车状态及稳定字段完整', dispatch, dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0, `status=${dispatched?.status || 'missing'}`, OPTIONS) + context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', + oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } }