You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/scripts/generate-accuracy-test-data.ts

662 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 批量准确度测试10 组远距离美国地址 → MotherShip 联想 → RPA 报价 → docs/测试数据.md
* 路径与 EmbeddedQuoteWidget 一致:
* POST /api/addresses/mothership-candidates → 确认候选 → POST /api/quotes → GET 轮询
* 前置npm run dev:startNext + 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<void> {
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<void> {
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<void> {
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<string, string> = {
standard: "标准",
guaranteed: "保证送达",
};
const optionMap: Record<string, string> = {
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<CaseResult> {
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 RPARPA_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<void> {
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);
});