/** * 人工验收:两组真实 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 { if (getRpaAddressMode() !== "real") { throw new Error("须 RPA_ADDRESS_MODE=real,Worker 重启后再跑"); } 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());