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/probe-random-address-quote.ts

405 lines
11 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.

/**
* 方案 A 随机地址报价探针
*
* 成功标准:询价 status=done 且 tiers>=1真实 axel/quote
*
* PowerShell:
* npm run probe:random-address-quote
* $env:PROBE_RANDOM_LOOPS="5"; npm run probe:random-address-quote
*/
import { randomUUID } from "node:crypto";
import { existsSync, 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,
} from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import type { MothershipAddressCandidate, QuoteRequestBody } from "@/lib/frontend/types";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { pickBestMothershipCandidate } from "./lib/pick-mothership-candidate";
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
process.env.RPA_MOCK_MODE = "false";
process.env.RPA_ADDRESS_MODE = "real";
process.env.RPA_DUAL_ADDRESS_PATCH = "true";
loadDevEnv();
const BASE = process.env.PROBE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
const LOOPS = Math.max(1, Number(process.env.PROBE_RANDOM_LOOPS ?? 3));
const POLL_TIMEOUT_MS = Number(
process.env.PROBE_RANDOM_POLL_MS ?? process.env.QUOTE_TIMEOUT_MS ?? 420_000,
);
type AddressDraft = {
street: string;
city: string;
state: string;
zip: string;
};
type AddressPair = {
id: string;
label: string;
pickup: Omit<AddressDraft, "zip">;
delivery: Omit<AddressDraft, "zip">;
cargo: {
pallet_count: number;
weight_lb: number;
length_in: number;
width_in: number;
height_in: number;
cargo_type: QuoteRequestBody["cargo_type"];
};
};
const ADDRESS_POOL: AddressPair[] = [
{
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: "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: "10",
label: "西雅图 Convention Center → 迈阿密 Beach",
pickup: {
street: "Washington State Convention Center Main Garage, Pike Street",
city: "Seattle",
state: "WA",
},
delivery: { street: "Ocean Drive", city: "Miami Beach", state: "FL" },
cargo: {
pallet_count: 2,
weight_lb: 500,
length_in: 48,
width_in: 40,
height_in: 48,
cargo_type: "general_freight",
},
},
{
id: "99",
label: "Key West FL → Los Angeles CA",
pickup: { street: "Duval Street", city: "Key West", state: "FL" },
delivery: { street: "Hollywood Boulevard", city: "Los Angeles", state: "CA" },
cargo: {
pallet_count: 2,
weight_lb: 503,
length_in: 48,
width_in: 40,
height_in: 48,
cargo_type: "general_freight",
},
},
{
id: "03",
label: "芝加哥 IL → 亚特兰大 GA",
pickup: { street: "233 S Wacker Dr", city: "Chicago", state: "IL" },
delivery: { street: "285 Andrew Young Intl Blvd", city: "Atlanta", state: "GA" },
cargo: {
pallet_count: 2,
weight_lb: 480,
length_in: 48,
width_in: 40,
height_in: 48,
cargo_type: "general_freight",
},
},
{
id: "04",
label: "达拉斯 TX → 凤凰城 AZ",
pickup: { street: "1500 Marilla St", city: "Dallas", state: "TX" },
delivery: { street: "200 W Washington St", city: "Phoenix", state: "AZ" },
cargo: {
pallet_count: 2,
weight_lb: 520,
length_in: 48,
width_in: 40,
height_in: 48,
cargo_type: "general_freight",
},
},
{
id: "05",
label: "波士顿 MA → 纳什维尔 TN",
pickup: { street: "1 Congress St", city: "Boston", state: "MA" },
delivery: { street: "501 Broadway", city: "Nashville", state: "TN" },
cargo: {
pallet_count: 2,
weight_lb: 490,
length_in: 48,
width_in: 40,
height_in: 48,
cargo_type: "general_freight",
},
},
];
function pickRandomPair(): AddressPair {
const idx = Math.floor(Math.random() * ADDRESS_POOL.length);
return ADDRESS_POOL[idx]!;
}
function uniqueWeight(base: number): number {
const nonce = (parseInt(randomUUID().replace(/-/g, ""), 16) % 900) + 1;
return base + nonce;
}
async function waitForHttpOk(url: string, attempts = 30): Promise<void> {
for (let i = 0; i < attempts; i += 1) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`服务未就绪: ${url}`);
}
function draftAddress(
draft: Omit<AddressDraft, "zip">,
): QuoteRequestBody["pickup_address"] {
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,
};
}
async function runOneCase(pair: AddressPair): Promise<{
ok: boolean;
quoteId?: string;
tierCount?: number;
source?: string;
error?: string;
}> {
const userPickup = { ...pair.pickup, zip: "" };
const userDelivery = { ...pair.delivery, zip: "" };
const cand = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
userPickup,
userDelivery,
);
if (cand.code !== 0 || !cand.data) {
return { ok: false, error: cand.message ?? "地址候选失败" };
}
const selectedPickup = pickBestMothershipCandidate(
cand.data.pickup_candidates,
userPickup,
);
const selectedDelivery = pickBestMothershipCandidate(
cand.data.delivery_candidates,
userDelivery,
);
if (!selectedPickup || !selectedDelivery) {
return { ok: false, error: "候选为空" };
}
const weightLb = uniqueWeight(pair.cargo.weight_lb);
const body: QuoteRequestBody = {
request_id: randomUUID(),
customer_id: CUSTOMER_ID,
quote_session_id: cand.data.quote_session_id,
pickup_address: applyMothershipCandidate(
draftAddress(userPickup),
selectedPickup,
),
delivery_address: applyMothershipCandidate(
draftAddress(userDelivery),
selectedDelivery,
),
weight: { value: weightLb, 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,
};
const created = await hostCreateQuote(BASE, TOKEN, body);
if (created.code !== 0 || !created.data?.quote_id) {
return { ok: false, error: created.message ?? "创建询价失败" };
}
const quoteId = created.data.quote_id;
let finalDetail = created.data;
if (finalDetail.status === "processing") {
const poll = 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: POLL_TIMEOUT_MS },
);
if (poll.type !== "done") {
return {
ok: false,
quoteId,
error: poll.type === "timeout" ? "轮询超时" : poll.message,
};
}
finalDetail = poll.quote;
}
const tierCount = finalDetail?.quotes?.length ?? 0;
const source = finalDetail?.source_type ?? "";
const ok =
finalDetail?.status === "done" &&
tierCount >= 1 &&
(source === "rpa" || source === "cache");
return {
ok,
quoteId,
tierCount,
source: String(source),
error: ok ? undefined : `status=${finalDetail?.status} tiers=${tierCount} source=${source}`,
};
}
async function main(): Promise<void> {
const runId = randomUUID().slice(0, 8);
await ensureNativeInfraIfNeeded();
resetRedisClient();
await ensureRedisReady();
await waitForHttpOk(`${BASE}/`);
const workerOk = await isAnyRpaWorkerHealthy();
if (!workerOk) {
console.warn("[probe:random] Worker 心跳未就绪,仍尝试运行(可能排队失败)");
}
console.log(
`[probe:random] 开始 ${LOOPS} 次随机地址 widget 报价dual-address-patch=on`,
);
const results: Array<{
pairId: string;
label: string;
ok: boolean;
quoteId?: string;
tierCount?: number;
source?: string;
error?: string;
}> = [];
let pass = 0;
let fail = 0;
for (let i = 0; i < LOOPS; i += 1) {
const pair = pickRandomPair();
console.log(`\n[probe:random] case ${i + 1}/${LOOPS}: ${pair.label}`);
const outcome = await runOneCase(pair);
results.push({
pairId: pair.id,
label: pair.label,
...outcome,
});
if (outcome.ok) {
pass += 1;
console.log(
`[PASS] ${pair.id} quote=${outcome.quoteId} tiers=${outcome.tierCount} source=${outcome.source}`,
);
} else {
fail += 1;
console.log(`[FAIL] ${pair.id} ${outcome.error ?? "unknown"}`);
}
}
const report = {
runId,
finishedAt: new Date().toISOString(),
loops: LOOPS,
pass,
fail,
verdict: fail === 0 ? "PASS" : "FAIL",
env: {
quoteStrategy: "direct-first-widget-fallback",
RPA_DUAL_ADDRESS_PATCH: "true",
RPA_ADDRESS_MODE: "real",
},
results,
};
const outDir = join(process.cwd(), ".rpa");
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const outPath = join(outDir, `random-address-quote-${runId}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2), "utf8");
console.log(`\n[probe:random] ${pass}/${LOOPS} 通过 | 报告: ${outPath}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});