118 lines
14 KiB
JavaScript
118 lines
14 KiB
JavaScript
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 })
|