/** * 批量准确度测试:10 组远距离美国地址 → MotherShip 联想 → RPA 报价 → docs/测试数据.md * 路径与 EmbeddedQuoteWidget 一致: * POST /api/addresses/mothership-candidates → 确认候选 → POST /api/quotes → GET 轮询 * 前置:npm run dev:start(Next + worker:rpa + worker-scheduler + Redis/MySQL) */ import { randomUUID } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadDevEnv } from "@/lib/dev-env"; import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra"; import { hostCreateQuote, hostFetchMothershipCandidates, hostGetQuote, hostPreheatQuoteSession, } from "@/lib/frontend/api-client"; import { applyMothershipCandidate } from "@/lib/frontend/mothership-address"; import type { AddressInput, MothershipAddressCandidate, QuoteRequestBody, } from "@/lib/frontend/types"; import { pollQuoteUntilDone } from "@/hooks/use-quote-polling"; import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health"; import { ensureRedisReady, resetRedisClient } from "@/lib/redis"; import { QUOTE_TIMEOUT_MS } from "@/lib/constants/quote"; import { recoverOrphanActiveJobs } from "@/workers/rpa/queue-recovery"; loadDevEnv(); const BASE = process.env.ACCURACY_TEST_BASE_URL ?? "http://localhost:3000"; const TOKEN = "demo-host-token"; const CUSTOMER_ID = "CUST_001"; const APP_READY_TIMEOUT_MS = 120_000; const STACK_POLL_MS = 3_000; const ACCURACY_TEST_QUOTE_TIMEOUT_MS = Number( process.env.ACCURACY_TEST_QUOTE_TIMEOUT_MS ?? QUOTE_TIMEOUT_MS, ); type AddressDraft = { street: string; city: string; state: string; }; type TestPair = { id: string; label: string; pickup: AddressDraft; delivery: AddressDraft; cargo: { pallet_count: number; weight_lb: number; length_in: number; width_in: number; height_in: number; cargo_type: string; }; }; const TEST_PAIRS: TestPair[] = [ { id: "01", label: "洛杉矶 CA → Farmers Branch TX(基线)", pickup: { street: "1234 Warehouse Street", city: "Los Angeles", state: "CA" }, delivery: { street: "5678 Distribution Way", city: "Farmers Branch", state: "TX" }, cargo: { pallet_count: 2, weight_lb: 500, length_in: 48, width_in: 40, height_in: 48, cargo_type: "general_freight" }, }, { id: "02", label: "西雅图 WA → 迈阿密 FL", pickup: { street: "3131 Western Ave", city: "Seattle", state: "WA" }, delivery: { street: "7000 NW 52nd St", city: "Miami", state: "FL" }, cargo: { pallet_count: 3, weight_lb: 550, length_in: 48, width_in: 40, height_in: 45, cargo_type: "general_freight" }, }, { id: "03", label: "芝加哥 IL → 凤凰城 AZ", pickup: { street: "1500 W Fulton St", city: "Chicago", state: "IL" }, delivery: { street: "4302 E Broadway Rd", city: "Phoenix", state: "AZ" }, cargo: { pallet_count: 2, weight_lb: 600, length_in: 47, width_in: 40, height_in: 48, cargo_type: "machinery" }, }, { id: "04", label: "波士顿 MA → 休斯顿 TX", pickup: { street: "10 World Trade Center Ave", city: "Boston", state: "MA" }, delivery: { street: "7701 Wallisville Rd", city: "Houston", state: "TX" }, cargo: { pallet_count: 4, weight_lb: 650, length_in: 48, width_in: 42, height_in: 46, cargo_type: "general_freight" }, }, { id: "05", label: "波特兰 OR → 亚特兰大 GA", pickup: { street: "2735 NW 29th Ave", city: "Portland", state: "OR" }, delivery: { street: "4000 Cumberland Blvd SE", city: "Atlanta", state: "GA" }, cargo: { pallet_count: 2, weight_lb: 700, length_in: 46, width_in: 40, height_in: 48, cargo_type: "furniture" }, }, { id: "06", label: "丹佛 CO → 夏洛特 NC", pickup: { street: "4800 Race St", city: "Denver", state: "CO" }, delivery: { street: "9300 South Blvd", city: "Charlotte", state: "NC" }, cargo: { pallet_count: 3, weight_lb: 450, length_in: 48, width_in: 40, height_in: 44, cargo_type: "general_freight" }, }, { id: "07", label: "明尼阿波利斯 MN → 圣安东尼奥 TX", pickup: { street: "900 Olson Memorial Hwy", city: "Minneapolis", state: "MN" }, delivery: { street: "6802 Bandera Rd", city: "San Antonio", state: "TX" }, cargo: { pallet_count: 2, weight_lb: 520, length_in: 48, width_in: 40, height_in: 42, cargo_type: "auto_parts" }, }, { id: "08", label: "底特律 MI → 杰克逊维尔 FL", pickup: { street: "1900 Fort St", city: "Detroit", state: "MI" }, delivery: { street: "6817 Southpoint Pkwy", city: "Jacksonville", state: "FL" }, cargo: { pallet_count: 3, weight_lb: 580, length_in: 45, width_in: 40, height_in: 48, cargo_type: "general_freight" }, }, { id: "09", label: "盐湖城 UT → 费城 PA", pickup: { street: "2350 S 900 W", city: "Salt Lake City", state: "UT" }, delivery: { street: "850 N 12th St", city: "Philadelphia", state: "PA" }, cargo: { pallet_count: 2, weight_lb: 480, length_in: 48, width_in: 38, height_in: 48, cargo_type: "electronics" }, }, { id: "10", label: "堪萨斯城 MO → 圣迭戈 CA", pickup: { street: "6800 Kansas Ave", city: "Kansas City", state: "MO" }, delivery: { street: "8585 Production Ave", city: "San Diego", state: "CA" }, cargo: { pallet_count: 4, weight_lb: 620, length_in: 48, width_in: 40, height_in: 47, cargo_type: "building_materials" }, }, ]; type QuoteTier = { service_level: string; rate_option: string; carrier: string; transit_days: string; transit_description: string; raw_freight: number; raw_total: number; final_total: number; }; type CaseResult = { pair: TestPair; startedAt: string; finishedAt: string; elapsedMs: number; pickupCandidates: MothershipAddressCandidate[]; deliveryCandidates: MothershipAddressCandidate[]; selectedPickup: MothershipAddressCandidate | null; selectedDelivery: MothershipAddressCandidate | null; quoteSessionId?: string; quoteId?: string; status: "done" | "failed" | "timeout" | "candidates_failed"; sourceType?: string; isRealtime?: boolean; errorCode?: string; errorMessage?: string; quotes: QuoteTier[]; }; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 与 QuoteForm 提交前地址结构一致(街道/城市/州,无邮编) */ function buildDraftAddress(draft: AddressDraft): AddressInput { const street = draft.street.trim(); const city = draft.city.trim(); const state = draft.state.trim(); return { street, city, state, zip: "", place_id: `draft_${street}_${city}_${state}`.replace(/\s+/g, "_").slice(0, 64), formatted_address: `${street}, ${city}, ${state}`, selected_from_suggestions: true, }; } function buildBaseQuoteBody(pair: TestPair): QuoteRequestBody { return { request_id: randomUUID(), customer_id: CUSTOMER_ID, pickup_address: buildDraftAddress(pair.pickup), delivery_address: buildDraftAddress(pair.delivery), weight: { value: pair.cargo.weight_lb, unit: "lb" }, dimensions: { length: pair.cargo.length_in, width: pair.cargo.width_in, height: pair.cargo.height_in, unit: "in", }, pallet_count: pair.cargo.pallet_count, cargo_type: pair.cargo.cargo_type as QuoteRequestBody["cargo_type"], }; } async function ensureInfraStableForCase(): Promise { ensureNativeInfraIfNeeded(); resetRedisClient(); await ensureRedisReady(12, 1_000); const recovered = await recoverOrphanActiveJobs(); if (recovered > 0) { console.warn(`[accuracy-test] 已回收 ${recovered} 条 orphan 队列 job`); } } async function ensureDevStackReady(): Promise { ensureNativeInfraIfNeeded(); try { await ensureInfraStableForCase(); } catch { /* 栈未就绪时下面轮询会重试 */ } const deadline = Date.now() + APP_READY_TIMEOUT_MS; let lastError = "未知"; let stableHits = 0; while (Date.now() < deadline) { try { ensureNativeInfraIfNeeded(); await ensureRedisReady(5, 500); const res = await fetch(BASE, { method: "GET", redirect: "manual" }); if (res.status <= 0) { lastError = "Next 未响应"; stableHits = 0; } else if (!(await isAnyRpaWorkerHealthy())) { lastError = "RPA Worker 无心跳(请 npm run dev:start)"; stableHits = 0; } else { stableHits += 1; if (stableHits >= 2) { console.log("[accuracy-test] 全栈就绪:Next + Redis + RPA Worker"); return; } } } catch (error) { lastError = error instanceof Error ? error.message : String(error); stableHits = 0; ensureNativeInfraIfNeeded(); resetRedisClient(); } await sleep(STACK_POLL_MS); } throw new Error( `开发栈未就绪(${lastError})。请执行:npm run dev:infra:start && npm run dev:start`, ); } function formatDraft(addr: AddressDraft): string { return `${addr.street}, ${addr.city}, ${addr.state}`; } function tierLabel(level: string, option: string): string { const levelMap: Record = { standard: "标准", guaranteed: "保证送达", }; const optionMap: Record = { lowest: "最低价", fastest: "最快", bestValue: "最优性价比", }; return `${levelMap[level] ?? level} / ${optionMap[option] ?? option}`; } async function fetchCandidatesViaHttp( baseBody: QuoteRequestBody, ): Promise< | { ok: true; data: MothershipCandidatesResult } | { ok: false; message: string } > { const res = await hostFetchMothershipCandidates( BASE, TOKEN, CUSTOMER_ID, baseBody.pickup_address, baseBody.delivery_address, ); if (res.code !== 0 || !res.data) { return { ok: false, message: res.message ?? "地址联想失败" }; } return { ok: true, data: res.data }; } async function submitAndPollQuote( body: QuoteRequestBody, ): Promise<{ quoteId: string; status: string; sourceType?: string; isRealtime?: boolean; errorCode?: string; errorMessage?: string; quotes: QuoteTier[]; }> { const created = await hostCreateQuote(BASE, TOKEN, body); if (created.code !== 0 || !created.data?.quote_id) { throw new Error(created.message ?? "创建询价失败"); } const quoteId = created.data.quote_id; if (created.data.status === "done") { const detail = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId); if (detail.code !== 0 || !detail.data) { throw new Error(detail.message ?? "查询报价失败"); } return { quoteId, status: detail.data.status, sourceType: detail.data.source_type ?? undefined, isRealtime: detail.data.is_realtime, errorCode: detail.data.error_code ?? undefined, errorMessage: detail.data.error_message ?? undefined, quotes: detail.data.quotes ?? [], }; } const pollResult = await pollQuoteUntilDone(async () => { const detail = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId); if (detail.code !== 0) { return { ok: false, errorMessage: detail.message }; } return { ok: true, data: detail.data }; }, { timeoutMs: ACCURACY_TEST_QUOTE_TIMEOUT_MS }); if (pollResult.type === "done") { const q = pollResult.quote; return { quoteId, status: q.status, sourceType: q.source_type ?? undefined, isRealtime: q.is_realtime, errorCode: q.error_code ?? undefined, errorMessage: q.error_message ?? undefined, quotes: q.quotes ?? [], }; } if (pollResult.type === "error") { return { quoteId, status: "failed", errorMessage: pollResult.message, quotes: [], }; } return { quoteId, status: "timeout", quotes: [], }; } async function runCase(pair: TestPair): Promise { const startedAt = new Date().toISOString(); const t0 = Date.now(); console.log(`\n[${pair.id}] ${pair.label} — 地址联想(HTTP API)…`); await ensureInfraStableForCase(); const baseBody = buildBaseQuoteBody(pair); let candidates; try { candidates = await fetchCandidatesViaHttp(baseBody); } catch (error) { return { pair, startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - t0, pickupCandidates: [], deliveryCandidates: [], selectedPickup: null, selectedDelivery: null, status: "candidates_failed", errorMessage: error instanceof Error ? error.message : "地址联想请求失败", quotes: [], }; } if (!candidates.ok) { return { pair, startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - t0, pickupCandidates: [], deliveryCandidates: [], selectedPickup: null, selectedDelivery: null, status: "candidates_failed", errorMessage: candidates.message, quotes: [], }; } const { pickup_candidates, delivery_candidates, quote_session_id } = candidates.data; if (quote_session_id) { await hostPreheatQuoteSession(BASE, TOKEN, CUSTOMER_ID, quote_session_id); } const selectedPickup = pickup_candidates[0] ?? null; const selectedDelivery = delivery_candidates[0] ?? null; if (!selectedPickup || !selectedDelivery) { return { pair, startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - t0, pickupCandidates: pickup_candidates, deliveryCandidates: delivery_candidates, selectedPickup, selectedDelivery, quoteSessionId: quote_session_id, status: "candidates_failed", errorMessage: "联想结果为空,无法确认地址", quotes: [], }; } console.log( `[${pair.id}] 确认联想:${selectedPickup.display_label} → ${selectedDelivery.display_label}`, ); console.log(`[${pair.id}] 询价中…`); const confirmedBody: QuoteRequestBody = { ...baseBody, request_id: randomUUID(), quote_session_id, pickup_address: applyMothershipCandidate( baseBody.pickup_address, selectedPickup, ), delivery_address: applyMothershipCandidate( baseBody.delivery_address, selectedDelivery, ), }; let quoteResult; try { quoteResult = await submitAndPollQuote(confirmedBody); } catch (error) { return { pair, startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - t0, pickupCandidates: pickup_candidates, deliveryCandidates: delivery_candidates, selectedPickup, selectedDelivery, quoteSessionId: quote_session_id, status: "failed", errorMessage: error instanceof Error ? error.message : "询价异常", quotes: [], }; } const status = quoteResult.status === "done" ? "done" : quoteResult.status === "timeout" ? "timeout" : "failed"; console.log( `[${pair.id}] 完成 status=${status} tiers=${quoteResult.quotes.length} quote_id=${quoteResult.quoteId}`, ); return { pair, startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - t0, pickupCandidates: pickup_candidates, deliveryCandidates: delivery_candidates, selectedPickup, selectedDelivery, quoteSessionId: quote_session_id, quoteId: quoteResult.quoteId, status, sourceType: quoteResult.sourceType, isRealtime: quoteResult.isRealtime, errorCode: quoteResult.errorCode, errorMessage: quoteResult.errorMessage, quotes: quoteResult.quotes, }; } function renderCandidateList(list: MothershipAddressCandidate[]): string { if (list.length === 0) { return "- (无候选)\n"; } return list .map( (c, i) => `${i + 1}. \`${c.display_label}\`(option_id: \`${c.option_id}\`,zip: ${c.zip || "—"})`, ) .join("\n"); } function renderMarkdown(results: CaseResult[], generatedAt: string): string { const doneCount = results.filter((r) => r.status === "done").length; const lines: string[] = [ "# 查价准确度测试数据", "", `> 生成时间:${generatedAt}`, `> 测试组数:10 | 成功报价:${doneCount} | 数据源:MotherShip RPA(RPA_MOCK_MODE=false)`, `> 查询路径:与内嵌组件相同 — POST /api/addresses/mothership-candidates → POST /api/quotes → GET 轮询`, `> 说明:每组为远距离美国工业/物流类街道地址;联想候选取 **第 1 项** 作为确认地址(多候选时请人工核对列表)。`, "", "## 汇总", "", "| 组号 | 路线 | 状态 | 报价档位数 | 单号 | 耗时 |", "|------|------|------|-----------|------|------|", ]; for (const r of results) { lines.push( `| ${r.pair.id} | ${r.pair.label} | ${r.status} | ${r.quotes.length} | ${r.quoteId ?? "—"} | ${Math.round(r.elapsedMs / 1000)}s |`, ); } lines.push("", "---", ""); for (const r of results) { lines.push(`## 测试组 ${r.pair.id}:${r.pair.label}`, ""); lines.push("### 用户填写地址", ""); lines.push("| 项目 | 内容 |"); lines.push("|------|------|"); lines.push(`| 提货街道 | ${r.pair.pickup.street} |`); lines.push(`| 提货城市/州 | ${r.pair.pickup.city}, ${r.pair.pickup.state} |`); lines.push(`| 派送街道 | ${r.pair.delivery.street} |`); lines.push(`| 派送城市/州 | ${r.pair.delivery.city}, ${r.pair.delivery.state} |`); lines.push(""); lines.push("### MotherShip 联想候选", ""); lines.push("#### 提货候选", ""); lines.push(renderCandidateList(r.pickupCandidates), ""); lines.push("#### 派送候选", ""); lines.push(renderCandidateList(r.deliveryCandidates), ""); lines.push("### 本次确认使用的联想地址", ""); if (r.selectedPickup && r.selectedDelivery) { lines.push(`- **提货**:\`${r.selectedPickup.display_label}\``); lines.push(`- **派送**:\`${r.selectedDelivery.display_label}\``); if (r.quoteSessionId) { lines.push(`- quote_session_id:\`${r.quoteSessionId}\``); } } else { lines.push("- (未确认)"); } lines.push(""); lines.push("### 货物信息", ""); lines.push("| 项目 | 值 |"); lines.push("|------|-----|"); lines.push(`| 托盘数 | ${r.pair.cargo.pallet_count} |`); lines.push(`| 单托重量 | ${r.pair.cargo.weight_lb} lb |`); lines.push( `| 尺寸 (in) | ${r.pair.cargo.length_in} × ${r.pair.cargo.width_in} × ${r.pair.cargo.height_in} |`, ); lines.push(`| 货物类型 | ${r.pair.cargo.cargo_type} |`); lines.push(""); lines.push("### 报价结果", ""); lines.push(`- **状态**:${r.status}`); if (r.quoteId) lines.push(`- **报价单号**:\`${r.quoteId}\``); if (r.sourceType) lines.push(`- **来源**:${r.sourceType}${r.isRealtime === false ? "(非实时)" : ""}`); if (r.errorCode) lines.push(`- **错误码**:${r.errorCode}`); if (r.errorMessage) lines.push(`- **错误信息**:${r.errorMessage}`); lines.push(""); if (r.quotes.length > 0) { lines.push("| 档位 | 承运商 | 时效 | 原始总价 (USD) | 客户最终价 (USD) |"); lines.push("|------|--------|------|----------------|------------------|"); const sorted = [...r.quotes].sort((a, b) => { const ka = `${a.service_level}/${a.rate_option}`; const kb = `${b.service_level}/${b.rate_option}`; return ka.localeCompare(kb); }); for (const q of sorted) { lines.push( `| ${tierLabel(q.service_level, q.rate_option)} | ${q.carrier} | ${q.transit_description || q.transit_days} | ${q.raw_total.toFixed(2)} | ${q.final_total.toFixed(2)} |`, ); } } else { lines.push("*无报价档位数据*"); } lines.push("", "---", ""); } return lines.join("\n"); } async function main(): Promise { if (process.env.RPA_MOCK_MODE === "true") { console.error("[FAIL] RPA_MOCK_MODE=true,请关闭 Mock 后再生成真实测试数据"); process.exit(1); } console.log("[accuracy-test] 等待全栈就绪…"); await ensureDevStackReady(); console.log(`[accuracy-test] 使用与用户相同的 HTTP API:${BASE}`); console.log( `[accuracy-test] 询价轮询上限 ${ACCURACY_TEST_QUOTE_TIMEOUT_MS / 1_000}s`, ); const generatedAt = new Date().toISOString(); const results: CaseResult[] = []; const progressPath = join(process.cwd(), ".rpa", "accuracy-test-progress.json"); mkdirSync(join(process.cwd(), ".rpa"), { recursive: true }); writeFileSync( join(process.cwd(), "docs", "测试数据.md"), `# 查价准确度测试数据\n\n> 生成中…\n`, "utf8", ); for (const pair of TEST_PAIRS.filter((p) => { const only = process.env.ACCURACY_TEST_ONLY?.split(",").map((s) => s.trim()); return !only?.length || only.includes(p.id); })) { const result = await runCase(pair); results.push(result); writeFileSync(progressPath, JSON.stringify({ generatedAt, results }, null, 2)); writeFileSync( join(process.cwd(), "docs", "测试数据.md"), renderMarkdown(results, generatedAt), "utf8", ); await sleep(15_000); } const md = renderMarkdown(results, generatedAt); const docPath = join(process.cwd(), "docs", "测试数据.md"); writeFileSync(docPath, md, "utf8"); console.log(`\n[OK] 已写入 ${docPath}`); console.log(`[OK] 进度 JSON:${progressPath}`); const done = results.filter((r) => r.status === "done").length; console.log(`[SUMMARY] 成功 ${done}/10`); if (done < 10) { process.exitCode = 1; } } main().catch((error) => { console.error("[FAIL]", error); process.exit(1); });