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.

232 lines
7.0 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.

/**
* 人工验收:两组真实 MotherShip 联想地址 + RPA 询价(禁止 fixture 占位价)
* 用法npm run demo:two-quotes
*/
import { randomUUID } from "node:crypto";
import { loadDevEnv } from "@/lib/dev-env";
import { getRpaAddressMode } from "@/lib/rpa/address-mode";
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 { QuoteRequestBody } from "@/lib/frontend/types";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
import { prisma } from "@/lib/prisma";
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
loadDevEnv();
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
const TOKEN = "demo-host-token";
const CUSTOMER_ID = "CUST_001";
const CASES = [
{
id: "A",
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: 612, length_in: 48, width_in: 40, height_in: 48 },
},
{
id: "B",
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: 723, length_in: 48, width_in: 40, height_in: 45 },
},
] as const;
function draftAddress(
a: (typeof CASES)[number]["pickup"],
): QuoteRequestBody["pickup_address"] {
const street = a.street.trim();
const city = a.city.trim();
const state = a.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 ensureStack(): Promise<void> {
if (getRpaAddressMode() !== "real") {
throw new Error("须 RPA_ADDRESS_MODE=realWorker 重启后再跑");
}
ensureNativeInfraIfNeeded();
resetRedisClient();
await ensureRedisReady(12, 1_000);
await prisma.$queryRaw`SELECT 1`;
const res = await fetch(BASE);
if (!res.ok && res.status !== 304) {
throw new Error("Next 未响应,请先 npm run dev:start");
}
if (!(await isAnyRpaWorkerHealthy())) {
throw new Error("RPA Worker 无心跳,请先 npm run dev:start");
}
}
function pickCandidate<
T extends { street: string; display_label: string },
>(candidates: T[], draft: { street: string }): T {
const num = draft.street.match(/^\d+/)?.[0];
if (num) {
const matched = candidates.find((c) => {
const blob = `${c.street} ${c.display_label}`.replace(/\s+/g, " ");
return blob.includes(num);
});
if (matched) {
return matched;
}
}
const first = candidates[0];
if (!first) {
throw new Error("联想无候选");
}
return first;
}
async function runCase(c: (typeof CASES)[number]) {
const candRes = await hostFetchMothershipCandidates(
BASE,
TOKEN,
CUSTOMER_ID,
{ ...c.pickup, zip: "" },
{ ...c.delivery, zip: "" },
);
if (candRes.code !== 0 || !candRes.data) {
throw new Error(candRes.message ?? "MotherShip 联想失败");
}
const pickup = pickCandidate(candRes.data.pickup_candidates, c.pickup);
const delivery = pickCandidate(candRes.data.delivery_candidates, c.delivery);
if (candRes.data.quote_session_id) {
await hostPreheatQuoteSession(
BASE,
TOKEN,
CUSTOMER_ID,
candRes.data.quote_session_id,
);
// 驻留页询价 job 会一次性 fillAddress+cargo+quote跳过 confirm 避免重复填址超时
}
let body: QuoteRequestBody = {
request_id: randomUUID(),
customer_id: CUSTOMER_ID,
pickup_address: applyMothershipCandidate(draftAddress(c.pickup), pickup),
delivery_address: applyMothershipCandidate(draftAddress(c.delivery), delivery),
weight: { value: c.cargo.weight_lb, unit: "lb" },
dimensions: {
length: c.cargo.length_in,
width: c.cargo.width_in,
height: c.cargo.height_in,
unit: "in",
},
pallet_count: c.cargo.pallet_count,
cargo_type: "general_freight",
};
let created = await hostCreateQuote(BASE, TOKEN, body);
for (let i = 0; i < 5; i++) {
if (
created.code === 0 &&
created.data?.status === "done" &&
created.data.source_type === "cache"
) {
body = {
...body,
request_id: randomUUID(),
weight: { value: c.cargo.weight_lb + i + 1, unit: "lb" },
};
created = await hostCreateQuote(BASE, TOKEN, body);
continue;
}
break;
}
if (created.code !== 0 || !created.data?.quote_id) {
throw new Error(created.message ?? "创建询价失败");
}
const quoteId = created.data.quote_id;
let detail;
if (created.data.status === "done") {
const got = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId);
detail = got.data;
} else {
const poll = await pollQuoteUntilDone(
async () => {
const got = await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId);
if (got.code !== 0) {
return { ok: false, errorMessage: got.message };
}
return { ok: true, data: got.data };
},
{ timeoutMs: 480_000 },
);
if (poll.type !== "done") {
throw new Error(poll.type === "timeout" ? "轮询超时" : poll.message);
}
detail = poll.quote;
}
return {
caseId: c.id,
label: c.label,
quoteId,
confirmed: {
pickup: pickup.display_label,
delivery: delivery.display_label,
},
input: {
pickup: `${c.pickup.street}, ${c.pickup.city}, ${c.pickup.state}`,
delivery: `${c.delivery.street}, ${c.delivery.city}, ${c.delivery.state}`,
pallets: c.cargo.pallet_count,
weight_lb: body.weight.value,
dims_in: `${c.cargo.length_in}x${c.cargo.width_in}x${c.cargo.height_in}`,
},
result: {
status: detail?.status,
source_type: detail?.source_type,
tiers: detail?.quotes?.map((q) => ({
tier: `${q.service_level}/${q.rate_option}`,
final_total_usd: q.final_total,
transit_days: q.transit_days,
})),
},
};
}
async function main() {
console.log("=== 两组询价人工验收MotherShip 真实联想 + real RPA===\n");
await ensureStack();
const results = [];
for (const c of CASES) {
console.log(`[${c.id}] 联想 + 提交:${c.label}`);
const t0 = Date.now();
const r = await runCase(c);
console.log(
`[${c.id}] 完成 quote_id=${r.quoteId} source=${r.result.source_type} 耗时 ${((Date.now() - t0) / 1000).toFixed(0)}s\n`,
);
results.push(r);
}
console.log(JSON.stringify(results, null, 2));
}
main()
.catch((e) => {
console.error("[FAIL]", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());