|
|
/**
|
|
|
* 批量准确度测试: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<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 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<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);
|
|
|
});
|