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/visual-quote-full-chain.ts

223 lines
7.7 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.

#!/usr/bin/env tsx
/**
* 可视全链验收MotherShip widget 填址 → 货物 → 提交报价(单浏览器 headed
*
* 用法:
* npm run visual:quote-full-chain
* $env:RPA_HEADLESS="false"; npm run visual:quote-full-chain
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadDevEnv } from "@/lib/dev-env";
import { shouldKeepBrowserOpen } from "@/lib/rpa/env";
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
import { RPAContext } from "@/workers/rpa/kernel/context";
import { runMothershipQuoteKernel } from "@/workers/rpa/kernel/orchestrator";
import {
stabilizeQuoteLandingPage,
waitForQuoterWidgetHydrated,
} from "@/workers/rpa/page-prep";
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
process.env.RPA_MOCK_MODE = "false";
process.env.RPA_ADDRESS_MODE = "real";
process.env.RPA_VISUAL_RUSH = process.env.RPA_VISUAL_RUSH ?? "true";
process.env.RPA_DUAL_ADDRESS_PATCH = process.env.RPA_DUAL_ADDRESS_PATCH ?? "false";
process.env.RPA_DWELL_MS = process.env.RPA_DWELL_MS ?? "3000";
process.env.RPA_HEADLESS = process.env.RPA_HEADLESS ?? "false";
process.env.RPA_SLOW_MO_MS = process.env.RPA_SLOW_MO_MS ?? "0";
process.env.RPA_HUMAN_PACING = process.env.RPA_HUMAN_PACING ?? "false";
process.env.RPA_KEEP_BROWSER_OPEN = process.env.RPA_KEEP_BROWSER_OPEN ?? "true";
loadDevEnv();
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
const PAIR = {
label: "真人录证 5519e0ea293 Pike St → 826 Ocean Dr",
pickup: { street: "293 Pike St", city: "Seattle", state: "WA" },
delivery: { street: "826 Ocean Dr", city: "Miami Beach", state: "FL" },
cargo: {
pallet_count: 2,
weight_lb: 500,
length_in: 48,
width_in: 40,
height_in: 48,
},
};
function draftAddress(d: { street: string; city: string; state: string }) {
return {
street: d.street.trim(),
city: d.city.trim(),
state: d.state.trim(),
zip: "",
place_id: `draft_${d.street}_${d.city}_${d.state}`.replace(/\s+/g, "_").slice(0, 64),
formatted_address: `${d.street}, ${d.city}, ${d.state}`,
selected_from_suggestions: true,
};
}
async function main(): Promise<void> {
let opened:
| Awaited<ReturnType<typeof openQuotePage>>
| null = null;
const runId = randomUUID().slice(0, 8);
const startedAt = Date.now();
console.log(`[visual] 全链验收 ${runId}${PAIR.label}`);
console.log("[visual] 阶段:候选 API → 打开浏览器 → 填址 → 货物 → 报价");
console.log("[visual] RPA_VISUAL_RUSH=true跳过锁定/契约闸门,填完即点报价)");
const userPickup = draftAddress(PAIR.pickup);
const userDelivery = draftAddress(PAIR.delivery);
const cand = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
userPickup,
userDelivery,
);
if (cand.code !== 0 || !cand.data?.pickup_candidates[0] || !cand.data.delivery_candidates[0]) {
throw new Error(cand.message ?? "候选 API 失败(请先 npm run dev:start");
}
const pc = cand.data.pickup_candidates[0];
const dc = cand.data.delivery_candidates[0];
const pickupApplied = applyMothershipCandidate(userPickup, pc);
const deliveryApplied = applyMothershipCandidate(userDelivery, dc);
const req: QuoteRequest = {
cargoHash: `visual_${runId}`,
pickup: {
street: pickupApplied.street,
city: pickupApplied.city,
state: pickupApplied.state,
zip: pickupApplied.zip ?? "",
placeId: pc.option_id,
formattedAddress: pickupApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: pc.option_id,
mothershipDisplayLabel: pc.display_label,
selectedFromMothership: true,
},
delivery: {
street: deliveryApplied.street,
city: deliveryApplied.city,
state: deliveryApplied.state,
zip: deliveryApplied.zip ?? "",
placeId: dc.option_id,
formattedAddress: deliveryApplied.formatted_address,
selectedFromSuggestions: true,
mothershipOptionId: dc.option_id,
mothershipDisplayLabel: dc.display_label,
selectedFromMothership: true,
},
palletCount: PAIR.cargo.pallet_count,
weightLb: PAIR.cargo.weight_lb,
dimsIn: {
l: PAIR.cargo.length_in,
w: PAIR.cargo.width_in,
h: PAIR.cargo.height_in,
},
cargoType: "general_freight",
};
console.log(`[visual] 提货:${pc.display_label}`);
console.log(`[visual] 派送:${dc.display_label}`);
opened = await openQuotePage(quotePageAdapter, false, undefined, {
freshCargoContext: true,
});
const ctx = RPAContext.fromSession(opened.page, opened.context);
console.log("[visual] 等待 widget 加载…");
await stabilizeQuoteLandingPage(ctx);
await waitForQuoterWidgetHydrated(ctx.page, 90_000);
console.log("[visual] ▶ 开始 kernel填址提货 → 派送)…");
const tAddr = Date.now();
const items = await runMothershipQuoteKernel(ctx, req);
const addrMs = Date.now() - tAddr;
const finalUrl = opened.page.url();
const report = {
runId,
pair: PAIR.label,
finishedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
kernelMs: addrMs,
finalUrl,
tierCount: items.length,
tiers: items.map((q) => ({
tier: `${q.serviceLevel}/${q.rateOption}`,
final_total: q.rawTotal,
})),
verdict:
items.length >= 4
? "PASS"
: items.length > 0
? "PARTIAL"
: finalUrl.includes("sign-up-quote")
? "PASS_PAGE"
: "FAIL",
};
const outDir = join(process.cwd(), ".rpa", "visual-full-chain");
mkdirSync(outDir, { recursive: true });
const reportPath = join(outDir, `visual-${runId}.json`);
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log("\n════════ 可视全链结果 ════════");
console.log(`档位数量: ${items.length}`);
for (const q of items.slice(0, 6)) {
console.log(` ${q.serviceLevel}/${q.rateOption}: $${q.rawTotal}`);
}
console.log(`verdict: ${report.verdict}`);
console.log(`报告: ${reportPath}`);
const success =
report.verdict === "PASS" ||
report.verdict === "PARTIAL" ||
report.verdict === "PASS_PAGE";
if (!shouldKeepBrowserOpen()) {
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 8_000);
if (dwellMs > 0) {
console.log(`[visual] 完成后停留 ${dwellMs}ms 便于核对页面…`);
await opened.page.waitForTimeout(dwellMs).catch(() => undefined);
}
await opened.page.close().catch(() => undefined);
await opened.context.close().catch(() => undefined);
await closeBrowser();
process.exit(success ? 0 : 1);
}
console.log("\n[visual] 浏览器保持打开,请核对页面报价。按 Ctrl+C 结束。");
await new Promise<void>((resolve) => {
process.once("SIGINT", () => resolve());
process.once("SIGTERM", () => resolve());
});
await opened.page.close().catch(() => undefined);
await opened.context.close().catch(() => undefined);
await closeBrowser();
process.exit(success ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 15_000);
if (process.env.RPA_HEADLESS === "false" && dwellMs > 0) {
console.log(`[visual] 失败后停留 ${dwellMs}ms便于观察卡点…`);
await new Promise((resolve) => setTimeout(resolve, dwellMs));
}
process.exit(1);
});