|
|
/**
|
|
|
* 与 MotherShip 人工页同参验收(Case A 截图)
|
|
|
* 1234 Warehouse St LA → 5678 Distribution Way Farmers Branch TX
|
|
|
* 2托 500lb 40×48×48 — 禁止引用截图价格,仅跑真实 RPA
|
|
|
*/
|
|
|
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,
|
|
|
hostConfirmMothershipAddresses,
|
|
|
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 PICKUP = {
|
|
|
street: "1234 Warehouse Street",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
} as const;
|
|
|
|
|
|
const DELIVERY = {
|
|
|
street: "5678 Distribution Way",
|
|
|
city: "Farmers Branch",
|
|
|
state: "TX",
|
|
|
} as const;
|
|
|
|
|
|
const CARGO = {
|
|
|
pallet_count: 2,
|
|
|
weight_lb: 500,
|
|
|
width_in: 40,
|
|
|
length_in: 48,
|
|
|
height_in: 48,
|
|
|
} as const;
|
|
|
|
|
|
function draftAddress(street: string, city: string, state: string) {
|
|
|
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,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
const EXPECTED_TIERS: Record<string, { price: number; days: string }> = {
|
|
|
"standard/lowest": { price: 287.23, days: "4" },
|
|
|
"standard/bestValue": { price: 345.0, days: "3" },
|
|
|
"standard/fastest": { price: 369.72, days: "2" },
|
|
|
"guaranteed/lowest": { price: 457.36, days: "3" },
|
|
|
"guaranteed/fastest": { price: 480.37, days: "2" },
|
|
|
};
|
|
|
|
|
|
function assertAlignedWithScreenshot(
|
|
|
tiers: Array<{
|
|
|
service_level: string;
|
|
|
rate_option: string;
|
|
|
final_total: number;
|
|
|
transit_days?: string;
|
|
|
}>,
|
|
|
): void {
|
|
|
const byKey = new Map(
|
|
|
tiers.map((t) => [`${t.service_level}/${t.rate_option}`, t]),
|
|
|
);
|
|
|
const mismatches: string[] = [];
|
|
|
for (const [key, exp] of Object.entries(EXPECTED_TIERS)) {
|
|
|
const got = byKey.get(key);
|
|
|
if (!got) {
|
|
|
mismatches.push(`${key}: 缺失`);
|
|
|
continue;
|
|
|
}
|
|
|
if (Math.abs(got.final_total - exp.price) > 0.02) {
|
|
|
mismatches.push(`${key}: 期望 $${exp.price} 实际 $${got.final_total}`);
|
|
|
}
|
|
|
const dayStr = got.transit_days ?? "";
|
|
|
if (!dayStr.includes(exp.days)) {
|
|
|
mismatches.push(`${key}: 期望 ${exp.days} 天 实际 ${dayStr}`);
|
|
|
}
|
|
|
}
|
|
|
if (mismatches.length > 0) {
|
|
|
throw new Error(`与截图未对齐:\n${mismatches.join("\n")}`);
|
|
|
}
|
|
|
console.log("\n[PASS] 五档价格与截图对齐");
|
|
|
}
|
|
|
|
|
|
async function main() {
|
|
|
if (getRpaAddressMode() !== "real") {
|
|
|
throw new Error("须 RPA_ADDRESS_MODE=real");
|
|
|
}
|
|
|
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 无心跳");
|
|
|
}
|
|
|
|
|
|
console.log("=== 截图同参真实询价(500lb / 5678 Distribution Way)===\n");
|
|
|
|
|
|
const candRes = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
{ ...PICKUP, zip: "" },
|
|
|
{ ...DELIVERY, zip: "" },
|
|
|
);
|
|
|
if (candRes.code !== 0 || !candRes.data) {
|
|
|
throw new Error(candRes.message ?? "联想失败");
|
|
|
}
|
|
|
|
|
|
const pickup = pickCandidate(candRes.data.pickup_candidates, PICKUP);
|
|
|
const delivery = pickCandidate(candRes.data.delivery_candidates, DELIVERY);
|
|
|
|
|
|
console.log("联想 pickup:", pickup.display_label);
|
|
|
console.log("联想 delivery:", delivery.display_label);
|
|
|
console.log(
|
|
|
"delivery 候选:",
|
|
|
candRes.data.delivery_candidates.map((c) => c.display_label).join(" | "),
|
|
|
);
|
|
|
|
|
|
if (candRes.data.quote_session_id) {
|
|
|
const sessionId = candRes.data.quote_session_id;
|
|
|
await hostPreheatQuoteSession(BASE, TOKEN, CUSTOMER_ID, sessionId);
|
|
|
const confirmRes = await hostConfirmMothershipAddresses(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
sessionId,
|
|
|
pickup,
|
|
|
delivery,
|
|
|
);
|
|
|
if (confirmRes.code !== 0) {
|
|
|
console.warn(
|
|
|
"[warn] 地址确认未成功,询价将重跑 fillAddress:",
|
|
|
confirmRes.message,
|
|
|
);
|
|
|
} else {
|
|
|
console.log("地址已确认并驻留 session:", sessionId.slice(0, 8));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const quoteSessionId = candRes.data.quote_session_id;
|
|
|
|
|
|
const body: QuoteRequestBody = {
|
|
|
request_id: randomUUID(),
|
|
|
customer_id: CUSTOMER_ID,
|
|
|
quote_session_id: quoteSessionId,
|
|
|
pickup_address: applyMothershipCandidate(draftAddress(PICKUP.street, PICKUP.city, PICKUP.state), pickup),
|
|
|
delivery_address: applyMothershipCandidate(
|
|
|
draftAddress(DELIVERY.street, DELIVERY.city, DELIVERY.state),
|
|
|
delivery,
|
|
|
),
|
|
|
weight: { value: CARGO.weight_lb, unit: "lb" },
|
|
|
dimensions: {
|
|
|
length: CARGO.length_in,
|
|
|
width: CARGO.width_in,
|
|
|
height: CARGO.height_in,
|
|
|
unit: "in",
|
|
|
},
|
|
|
pallet_count: 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.request_id = randomUUID();
|
|
|
body.weight = { value: 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") {
|
|
|
detail = (await hostGetQuote(BASE, TOKEN, CUSTOMER_ID, quoteId)).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;
|
|
|
}
|
|
|
|
|
|
const out = {
|
|
|
quoteId,
|
|
|
input: {
|
|
|
pickup: `${PICKUP.street}, ${PICKUP.city}, ${PICKUP.state}`,
|
|
|
delivery: `${DELIVERY.street}, ${DELIVERY.city}, ${DELIVERY.state}`,
|
|
|
confirmed_delivery: delivery.display_label,
|
|
|
pallets: CARGO.pallet_count,
|
|
|
weight_lb: body.weight.value,
|
|
|
dims_in: `${CARGO.width_in}W×${CARGO.length_in}L×${CARGO.height_in}H`,
|
|
|
},
|
|
|
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,
|
|
|
})),
|
|
|
},
|
|
|
};
|
|
|
|
|
|
console.log("\n" + JSON.stringify(out, null, 2));
|
|
|
|
|
|
if (detail?.quotes?.length) {
|
|
|
assertAlignedWithScreenshot(detail.quotes);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
main()
|
|
|
.catch((e) => {
|
|
|
console.error("[FAIL]", e);
|
|
|
process.exit(1);
|
|
|
})
|
|
|
.finally(() => prisma.$disconnect());
|