import { readFile, mkdir, open } from 'node:fs/promises' import { resolve, dirname } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { parseEnv } from 'node:util' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { requireTestProfile, installQuoteJournal } from './fleet-quotes-test-env.mjs' import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const MARKER = 'FLEET-BOOKING-QUOTE-20261003-R2' const TRIPS = '/api/platform/v1/last-leg/fleet/trips', QUOTES = '/api/platform/v1/last-leg/fleet/quotes' export function warehouseQuoteContext(detail, planned) { const w = detail?.warehouse if (!w?.id || String(w.tenantId) !== '0' || w.status !== 'ACTIVE' || !['OVERSEAS', 'OVERSEAS_PARTNER'].includes(w.warehouseType) || !w.timezone) throw Error('Warehouse quote context unavailable') const cities = [...new Set((detail.addresses || []).filter(a => String(a.warehouseId) === String(w.id) && String(a.tenantId) === '0' && a.city?.trim()).map(a => a.city.trim().toLowerCase()))] if (cities.length !== 1) throw Error('Warehouse quote city ambiguous') const offset = /^(?:UTC)?([+-])(\d{1,2})(?::(\d{2}))?$/.exec(w.timezone) if (offset && (Number(offset[2]) > 18 || Number(offset[3] || 0) > 59 || Number(offset[2]) === 18 && Number(offset[3] || 0) !== 0)) throw Error('Warehouse offset invalid') const day = offset ? new Date(Date.parse(planned) + (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3] || 0)) * 60000).toISOString().slice(0, 10) : new Intl.DateTimeFormat('en-CA', { timeZone: w.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(planned)) return { warehouseId: String(w.id), originCity: cities[0], timezone: w.timezone, day } } export async function verifyBookingQuotes(context, warehouse, planned) { if (context.createdResources.bookingQuoteAcceptance || context.createdResources.merchantQualificationFixture?.marker !== context.marker) throw Error('Fresh own fixture required; no restart') const proof = context.createdResources.bookingQuoteAcceptance = { marker: context.marker, status: 'CHECKING', warehouse, quotes: [], bookings: [], checks: [], confirmationSuccess: 'NOT_TESTED_REQUIRES_LOAD_PLAN', receiptAndDeparture: 'NOT_TESTED' } const check = (name, pass) => { proof.checks.push({ name, passed: Boolean(pass) }); if (!pass) throw Error(name) } const request = async (method, path, body, expected = '200') => { const r = await context.request(method, path, body) check(`${method} ${path.split('?')[0]} => ${expected}`, r.http === 200 && String(r.body?.code) === expected && r.requestId && r.traceId) return r.body.data } const customerId = String(context.createdResources.merchantQualificationFixture.tenantId) const destination = 'QA-BOOKING', stops = [{ name: '本轮报价测试起运仓' }, { name: destination, destinationKey: `FBA:${destination}` }] let trip = (await request('POST', TRIPS, { warehouseId: warehouse.warehouseId, plannedDepartureAt: planned, expectedDriverEndAt: new Date(Date.parse(planned) + 3600000).toISOString(), stops, remark: context.marker, idempotencyKey: `${context.marker}-trip` })).trip proof.tripNo = trip.tripNo const path = `${TRIPS}/${trip.tripNo}/bookings` const detail = { kind: 'SALE', partyId: customerId, originCity: warehouse.originCity, destinationCode: destination, distanceMiles: 10, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 25, looseDeliveryPrice: 10, validFrom: warehouse.day, validTo: warehouse.day, remark: context.marker } const createQuote = async (suffix, change) => { const quote = await request('POST', QUOTES, { idempotencyKey: `${context.marker}-${suffix}`, details: { ...detail, ...change } }); proof.quotes.push(quote.quoteNo); return quote } const valid = await createQuote('valid', {}) const expiredDay = new Date(Date.parse(`${warehouse.day}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) await createQuote('expired', { distanceMiles: 11, validFrom: expiredDay, validTo: expiredDay }) const cny = await createQuote('cny', { distanceMiles: 12, currency: 'CNY', fullDeliveryPrice: 35, looseDeliveryPrice: null }) const zero = await createQuote('zero', { distanceMiles: 13, fullDeliveryPrice: null, looseDeliveryPrice: 0 }) const candidates = (currency, priceBasis) => request('GET', `${path}/quote-candidates?${new URLSearchParams({ customerId, destinationKey: `FBA:${destination}`, currency, priceBasis, page: '1', size: '100' })}`) const pallet = await candidates('USD', 'PALLET'), order = await candidates('USD', 'ORDER'), cnyPallet = await candidates('CNY', 'PALLET'), cnyOrder = await candidates('CNY', 'ORDER') check('USD pallet candidate excludes expired/currency mismatch but preserves explicit zero', pallet.records.length === 2 && pallet.records.some(q => q.quoteNo === zero.quoteNo && Number(q.quotedUnitPrice) === 0) && pallet.records.some(q => q.quoteNo === valid.quoteNo)) check('ORDER selects full-delivery instead of loose-delivery price', order.records.length === 1 && order.records[0].quoteNo === valid.quoteNo && order.records[0].priceItem === 'FULL_DELIVERY' && Number(order.records[0].quotedUnitPrice) === 25) check('Missing CNY pallet price is not zero', cnyPallet.records.length === 0 && cnyOrder.records.length === 1 && cnyOrder.records[0].quoteNo === cny.quoteNo) check('Server date equals authoritative warehouse local planned day', pallet.records.every(q => q.effectiveOn === warehouse.day)) const cargo = { destinationKey: `FBA:${destination}`, cargoName: '报价联动测试', packageCount: 2, palletSpaces: 2, weightKg: 10, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 7, currency: 'USD', contactName: null, contactPhone: null, remark: context.marker } const reference = { quoteNo: valid.quoteNo, recordVersion: valid.recordVersion, priceItem: 'LOOSE_DELIVERY' } const firstBody = { tripRecordVersion: trip.recordVersion, customerId, cargo, quoteRef: reference, idempotencyKey: `${context.marker}-booking` } const first = await request('POST', path, firstBody); trip.recordVersion = first.tripRecordVersion; proof.bookings.push(first.booking.bookingNo) check('Source price frozen separately from negotiated amount', Number(first.booking.quoteSource.quotedUnitPrice) === 10 && Number(first.booking.cargo.unitPrice) === 7 && Number(first.booking.agreedAmount) === 14 && String(first.booking.quoteSource.recordVersion) === String(valid.recordVersion)) const manual = await request('POST', path, { tripRecordVersion: trip.recordVersion, customerId, cargo: { ...cargo, unitPrice: 0 }, quoteRef: null, idempotencyKey: `${context.marker}-manual` }); trip.recordVersion = manual.tripRecordVersion; proof.bookings.push(manual.booking.bookingNo) check('Manual zero-price draft never gains a mandatory source', manual.booking.quoteSource == null && Number(manual.booking.agreedAmount) === 0) const changed = await request('PUT', `${QUOTES}/${valid.quoteNo}`, { recordVersion: valid.recordVersion, details: { ...detail, looseDeliveryPrice: 12 } }) const replay = await request('POST', path, firstBody) check('Exact create replay retains original source snapshot after quote edit', replay.replayed === true && Number(replay.booking.quoteSource.quotedUnitPrice) === 10) await request('POST', path, { ...firstBody, tripRecordVersion: trip.recordVersion, idempotencyKey: `${context.marker}-stale` }, 'A0409') await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, idempotencyKey: `${context.marker}-stale-confirm` }, 'A0409') const afterReject = await request('GET', path) check('Stale source rejection does not mutate booking or parent version', String(afterReject.tripRecordVersion) === String(trip.recordVersion) && afterReject.records.find(b => b.bookingNo === first.booking.bookingNo)?.status === 'DRAFT') const refreshed = await candidates('USD', 'PALLET'), current = refreshed.records.find(q => q.quoteNo === valid.quoteNo) check('Explicit refresh exposes new source version and rate', String(current?.recordVersion) === String(changed.recordVersion) && Number(current?.quotedUnitPrice) === 12) const updated = await request('PUT', `${path}/${first.booking.bookingNo}`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, cargo, quoteRef: { ...reference, recordVersion: current.recordVersion } }); trip.recordVersion = updated.tripRecordVersion check('Explicit reselection refreshes source while preserving negotiated price', Number(updated.booking.quoteSource.quotedUnitPrice) === 12 && Number(updated.booking.agreedAmount) === 14) const later = new Date(Date.parse(planned) + 2 * 86400000).toISOString() trip = (await request('PUT', `${TRIPS}/${trip.tripNo}/plan`, { recordVersion: trip.recordVersion, plannedDepartureAt: later, expectedDriverEndAt: new Date(Date.parse(later) + 3600000).toISOString(), stops, remark: context.marker, reason: '仅本轮报价有效日期验证' })).trip check('Plan date outside validity removes candidates', (await candidates('USD', 'PALLET')).records.length === 0) await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: updated.booking.recordVersion, idempotencyKey: `${context.marker}-expired-confirm` }, 'A0409') proof.finalBookings = await request('GET', path); proof.status = 'VERIFIED_DRAFT_SOURCE_LINKAGE'; proof.cleanup = 'RETAINED_OWN_PLANS_QUOTES_AND_DRAFTS_NO_RESOURCE_ASSIGNMENT' return proof } export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { const envFile = requireTestProfile(args, execArgs), env = parseEnv(await readFile(envFile, 'utf8')) if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('TEST credentials missing') const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw Error('Invalid TEST API') // R1 stopped before creating any business fixture because its offset parser rejected valid Java ZoneId UTC-8. // Preserve that evidence. This corrected run has a distinct claim, marker and request-key namespace. const directory = resolve(root, 'reports/fleet-booking-quotes-live-20261003-r2') await mkdir(directory, { recursive: true }) const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: `选价测试客户-${MARKER}`, quiet: true }) context.marker = MARKER const journal = await installQuoteJournal(context, directory, { secrets: [env.CHONGCHONG_ADMIN_PASSWORD, env.CHONGCHONG_ADMIN_USERNAME], persist: async (name, data) => { const file = await open(resolve(directory, name), 'wx', 0o600) try { await file.writeFile(JSON.stringify({ ...data, marker: MARKER, scope: 'BOOKING_QUOTE_ONLY', deployedCommit: '4ff55ba3e08c7fe0c6e04832fa95ebb680c76b8c' }, null, 2)); await file.sync() } finally { await file.close() } } }) let error = null, loggedOut = false try { const login = await context.request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') if (login.http !== 200 || String(login.body?.code) !== '200' || !login.body.data?.accessToken || !login.body.data?.refreshToken) throw Error('Login failed') context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken const options = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') if (String(options.body?.code) !== '200' || !Array.isArray(options.body.data)) throw Error('Warehouse directory failed') const planned = new Date(Date.now() + 86400000).toISOString(); let warehouse for (const option of options.body.data.slice(0, 20)) { const detail = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(option.id)}?tenantId=0`) if (String(detail.body?.code) !== '200') throw Error('Warehouse details failed') try { warehouse = warehouseQuoteContext(detail.body.data, planned); break } catch { /* Read next real active warehouse; never write missing master data. */ } } if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created') context.createdResources.quoteWarehouse = warehouse await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false, requireDistinctCustomer: false })) if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed') await verifyBookingQuotes(context, warehouse, planned) } catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' } finally { try { if (context.token) { const r = await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken }); loggedOut = r.http === 200 && String(r.body?.code) === '200' } } catch { error ||= 'Logout outcome unknown' } context.token = ''; context.refreshToken = '' } const report = { success: !error && loggedOut && !journal.unknown(), error, loggedOut, createdResources: context.createdResources, steps: context.steps, ui: 'NOT_TESTED', operations: 'NOT_TESTED' } await journal.checkpoint('final', report) return { success: report.success, directory } } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) execute().then(r => { console.log(JSON.stringify(r)); process.exitCode = r.success ? 0 : 1 }).catch(() => { console.error('Booking quote run blocked; preserve checkpoints, no automatic rerun.'); process.exitCode = 1 })