|
|
/**
|
|
|
* 方案 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);
|
|
|
});
|