diff --git a/scripts/README.md b/scripts/README.md index bad458f43..0fdaff748 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 + `deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 `client-address-isolation-test-env.mjs` 是客户地址提交隔离的 TEST 定向负向入口,默认 `--plan` 零网络;执行要求显式 TEST 配置及 `--execute --environment=TEST --run-id=<新批次> --customer-profile=<同目录test-ui-*.env>`。分别登录两个既有客户,从来源客户读取仍有效的取货/私人地址、从目标客户读取实际仓库和渠道;向目标客户发两次单单与两次单行批量 JSON 创建请求,必须精准返回“客户地址不存在或已停用”,普通必填错误不算通过。每个载荷只含一种外客户地址,成功返回订单或未知结果立即停止,报告保存幂等键/载荷摘要/意外订单标识供定位,不自动重试、删除或恢复;结束退出两个独立会话。独占报告 `reports/client-address-isolation-/`,不记录凭据、地址明文或账号。该项不等于 XLSX 解析拒绝、数据库零漂移或真实页面验证;对应 `.test.mjs` 检查精准错误分类与探针载荷。 diff --git a/scripts/fleet-booking-quotes-test-env-contract.test.mjs b/scripts/fleet-booking-quotes-test-env-contract.test.mjs new file mode 100644 index 000000000..d145b5071 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env-contract.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { warehouseQuoteContext, verifyBookingQuotes } from './fleet-booking-quotes-test-env.mjs' + +test('warehouse date uses authoritative timezone and rejects ambiguous or customer-owned warehouse', () => { + const detail = { warehouse: { id: '92', tenantId: '0', status: 'ACTIVE', warehouseType: 'OVERSEAS', timezone: 'America/Los_Angeles' }, addresses: [{ warehouseId: '92', tenantId: '0', city: 'LA' }] } + assert.equal(warehouseQuoteContext(detail, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.equal(warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, timezone: 'UTC-8' } }, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.throws(() => warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, tenantId: '1' } }, '2026-10-04T01:00:00Z')) + assert.throws(() => warehouseQuoteContext({ ...detail, addresses: [...detail.addresses, { ...detail.addresses[0], city: 'SF' }] }, '2026-10-04T01:00:00Z')) +}) +function harness(fault) { + const quotes = [], bookings = [], replay = new Map(), calls = [] + let trip = { tripNo: 'TR-OWN', recordVersion: 0 }, expired = false + const context = { marker: 'TEST-CURRENT', createdResources: { merchantQualificationFixture: { marker: 'TEST-CURRENT', tenantId: '9' } }, async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }); const url = new URL(path, 'http://offline.invalid'), p = url.pathname + const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }) + if (p.endsWith('/quote-candidates')) { + const basis = url.searchParams.get('priceBasis'), currency = url.searchParams.get('currency') + return response({ records: quotes.filter(q => !expired && q.details.validFrom === '2026-10-04' && q.details.currency === currency && q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'] != null).map(q => ({ quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'], priceItem: basis === 'PALLET' ? 'LOOSE_DELIVERY' : 'FULL_DELIVERY', effectiveOn: '2026-10-04' })) }) + } + if (p.endsWith('/quotes') && method === 'POST') { const q = { quoteNo: `FQ-${quotes.length}`, recordVersion: 0, details: body.details }; quotes.push(q); return response(q) } + if (p.includes('/quotes/FQ-')) { const q = quotes.find(q => p.endsWith(q.quoteNo)); q.details = body.details; q.recordVersion++; return response(q) } + if (p.endsWith('/trips') && method === 'POST') return response({ trip }) + if (p.endsWith('/plan')) { expired = true; trip.recordVersion++; return response({ trip }) } + if (p.endsWith('/confirm')) return response(null, fault === 'accepts-stale' ? '200' : 'A0409') + if (p.endsWith('/bookings') && method === 'GET') return response({ tripRecordVersion: trip.recordVersion, records: bookings }) + if (p.endsWith('/bookings') && replay.has(body.idempotencyKey)) return response({ ...replay.get(body.idempotencyKey), replayed: true }) + const q = body.quoteRef ? quotes.find(q => q.quoteNo === body.quoteRef.quoteNo) : null + if (q && q.recordVersion !== body.quoteRef.recordVersion) return response(null, 'A0409') + let b + if (method === 'POST') { b = { bookingNo: `FB-${bookings.length}`, status: 'DRAFT', recordVersion: 0 }; bookings.push(b) } + else { b = bookings.find(b => p.endsWith(b.bookingNo)); b.recordVersion++ } + Object.assign(b, { cargo: body.cargo, agreedAmount: body.cargo.unitPrice * body.cargo.palletSpaces, quoteSource: q ? { quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details.looseDeliveryPrice } : null }) + trip.recordVersion++ + const result = { tripRecordVersion: trip.recordVersion, booking: b, replayed: false } + if (method === 'POST') replay.set(body.idempotencyKey, structuredClone(result)) + return response(result) + } } + return { context, calls } +} +test('independent draft fixture covers source version, date, currency, manual pricing without inventory or departure writes', async () => { + const h = harness(), proof = await verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z') + assert.equal(proof.status, 'VERIFIED_DRAFT_SOURCE_LINKAGE'); assert.ok(proof.checks.every(c => c.passed)) + assert.ok(h.calls.every(c => !/inventory|\/depart$|\/assignment$|\/receipt-confirmation$/.test(c.path))) + await assert.rejects(verifyBookingQuotes(h.context, {}, ''), /no restart/) +}) +test('unexpected acceptance of stale confirmation cannot be reported as successful', async () => { + const h = harness('accepts-stale') + await assert.rejects(verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z'), /A0409/) +}) diff --git a/scripts/fleet-booking-quotes-test-env.mjs b/scripts/fleet-booking-quotes-test-env.mjs new file mode 100644 index 000000000..4ab271d83 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env.mjs @@ -0,0 +1,117 @@ +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 })) + 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 })