|
|
#!/usr/bin/env tsx
|
|
|
/**
|
|
|
* 人工 docx 10 组 — 系统全链路探针(HTTP API,非 RPA 代码直调)
|
|
|
*
|
|
|
* 流程:候选 API → POST /api/quotes → 轮询 GET /api/quotes/:id → 校验 done + tiers
|
|
|
* 地址来源:docs/美国跨州货物运输报价汇总_edited3.docx
|
|
|
*
|
|
|
* 前置:npm run dev:start(Next + RPA Worker + Redis + MySQL)
|
|
|
*
|
|
|
* PowerShell:
|
|
|
* npm run probe:human-doc-system
|
|
|
* $env:PROBE_HUMAN_ONLY="H01,H07"; npm run probe:human-doc-system
|
|
|
*/
|
|
|
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 { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
|
|
|
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
|
|
|
import {
|
|
|
HUMAN_DOC_10_PAIRS,
|
|
|
HUMAN_DOC_CARGO,
|
|
|
type HumanDocPair,
|
|
|
} from "./lib/human-doc-10pairs";
|
|
|
import { pickBestMothershipCandidate } from "./lib/pick-mothership-candidate";
|
|
|
|
|
|
loadDevEnv();
|
|
|
|
|
|
const BASE = process.env.PROBE_BASE_URL ?? "http://localhost:3000";
|
|
|
const TOKEN = "demo-host-token";
|
|
|
const CUSTOMER_ID = "CUST_001";
|
|
|
const POLL_TIMEOUT_MS = Number(
|
|
|
process.env.PROBE_HUMAN_POLL_MS ?? process.env.QUOTE_TIMEOUT_MS ?? 600_000,
|
|
|
);
|
|
|
const ONLY = (process.env.PROBE_HUMAN_ONLY ?? "")
|
|
|
.split(",")
|
|
|
.map((s) => s.trim().toUpperCase())
|
|
|
.filter(Boolean);
|
|
|
|
|
|
function selectPairs(): HumanDocPair[] {
|
|
|
if (!ONLY.length) {
|
|
|
return HUMAN_DOC_10_PAIRS;
|
|
|
}
|
|
|
return HUMAN_DOC_10_PAIRS.filter((p) => ONLY.includes(p.id));
|
|
|
}
|
|
|
|
|
|
function uniqueWeight(base: number): number {
|
|
|
const nonce = (parseInt(randomUUID().replace(/-/g, ""), 16) % 900) + 1;
|
|
|
return base + nonce;
|
|
|
}
|
|
|
|
|
|
async function waitForHttpOk(url: string, attempts = 60): 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}(请先 npm run dev:start)`);
|
|
|
}
|
|
|
|
|
|
function draftAddress(
|
|
|
draft: { street: string; city: string; state: string },
|
|
|
): 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,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function fmtMs(ms: number): string {
|
|
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
|
const m = Math.floor(ms / 60_000);
|
|
|
const s = ((ms % 60_000) / 1000).toFixed(0);
|
|
|
return `${m}m${s}s`;
|
|
|
}
|
|
|
|
|
|
type CaseResult = {
|
|
|
id: string;
|
|
|
route: string;
|
|
|
ok: boolean;
|
|
|
quoteId?: string;
|
|
|
tierCount?: number;
|
|
|
sourceType?: string;
|
|
|
durationMs: number;
|
|
|
pickupLabel?: string;
|
|
|
deliveryLabel?: string;
|
|
|
quotes?: Array<{
|
|
|
serviceLevel: string;
|
|
|
rateOption: string;
|
|
|
rawTotal: number;
|
|
|
carrier?: string;
|
|
|
}>;
|
|
|
error?: string;
|
|
|
};
|
|
|
|
|
|
async function runSystemQuote(pair: HumanDocPair): Promise<CaseResult> {
|
|
|
const started = Date.now();
|
|
|
const userPickup = { ...pair.pickup, zip: "" };
|
|
|
const userDelivery = { ...pair.delivery, zip: "" };
|
|
|
|
|
|
try {
|
|
|
const cand = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
userPickup,
|
|
|
userDelivery,
|
|
|
);
|
|
|
if (cand.code !== 0 || !cand.data) {
|
|
|
return {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok: false,
|
|
|
durationMs: Date.now() - started,
|
|
|
error: cand.message ?? "地址候选 API 失败",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const selectedPickup = pickBestMothershipCandidate(
|
|
|
cand.data.pickup_candidates,
|
|
|
userPickup,
|
|
|
);
|
|
|
const selectedDelivery = pickBestMothershipCandidate(
|
|
|
cand.data.delivery_candidates,
|
|
|
userDelivery,
|
|
|
);
|
|
|
if (!selectedPickup || !selectedDelivery) {
|
|
|
return {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok: false,
|
|
|
durationMs: Date.now() - started,
|
|
|
error: "地址候选为空",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
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: uniqueWeight(HUMAN_DOC_CARGO.weightLb), unit: "lb" },
|
|
|
dimensions: {
|
|
|
length: HUMAN_DOC_CARGO.dimsIn.length,
|
|
|
width: HUMAN_DOC_CARGO.dimsIn.width,
|
|
|
height: HUMAN_DOC_CARGO.dimsIn.height,
|
|
|
unit: "in",
|
|
|
},
|
|
|
pallet_count: HUMAN_DOC_CARGO.palletCount,
|
|
|
cargo_type: HUMAN_DOC_CARGO.cargoType,
|
|
|
};
|
|
|
|
|
|
const created = await hostCreateQuote(BASE, TOKEN, body);
|
|
|
if (created.code !== 0 || !created.data?.quote_id) {
|
|
|
return {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok: false,
|
|
|
durationMs: Date.now() - started,
|
|
|
error: created.message ?? "POST /api/quotes 失败",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
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 {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok: false,
|
|
|
quoteId,
|
|
|
durationMs: Date.now() - started,
|
|
|
error:
|
|
|
poll.type === "timeout"
|
|
|
? `轮询超时(${fmtMs(POLL_TIMEOUT_MS)})`
|
|
|
: poll.message,
|
|
|
};
|
|
|
}
|
|
|
finalDetail = poll.quote;
|
|
|
}
|
|
|
|
|
|
const tierCount = finalDetail?.quotes?.length ?? 0;
|
|
|
const sourceType = String(finalDetail?.source_type ?? "");
|
|
|
const ok =
|
|
|
finalDetail?.status === "done" &&
|
|
|
tierCount >= 1 &&
|
|
|
(sourceType === "rpa" || sourceType === "cache");
|
|
|
|
|
|
return {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok,
|
|
|
quoteId,
|
|
|
tierCount,
|
|
|
sourceType,
|
|
|
durationMs: Date.now() - started,
|
|
|
pickupLabel: selectedPickup.display_label,
|
|
|
deliveryLabel: selectedDelivery.display_label,
|
|
|
quotes: (finalDetail?.quotes ?? []).map((q) => ({
|
|
|
serviceLevel: q.service_level,
|
|
|
rateOption: q.rate_option,
|
|
|
rawTotal: q.raw_total,
|
|
|
carrier: q.carrier,
|
|
|
})),
|
|
|
error: ok
|
|
|
? undefined
|
|
|
: `status=${finalDetail?.status} tiers=${tierCount} source=${sourceType}`,
|
|
|
};
|
|
|
} catch (error) {
|
|
|
return {
|
|
|
id: pair.id,
|
|
|
route: pair.route,
|
|
|
ok: false,
|
|
|
durationMs: Date.now() - started,
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
const runId = randomUUID().slice(0, 8);
|
|
|
const pairs = selectPairs();
|
|
|
if (!pairs.length) {
|
|
|
console.error(`[probe:human-doc] 无匹配路线 PROBE_HUMAN_ONLY=${ONLY.join(",")}`);
|
|
|
process.exit(1);
|
|
|
}
|
|
|
|
|
|
await ensureNativeInfraIfNeeded();
|
|
|
resetRedisClient();
|
|
|
await ensureRedisReady();
|
|
|
await waitForHttpOk(`${BASE}/`);
|
|
|
|
|
|
const workerOk = await isAnyRpaWorkerHealthy();
|
|
|
console.log(
|
|
|
`[probe:human-doc] 系统全链路探针 run=${runId} pairs=${pairs.length} worker=${workerOk ? "ok" : "未就绪"}`,
|
|
|
);
|
|
|
console.log(`[probe:human-doc] API=${BASE} 策略=direct-first-widget-fallback`);
|
|
|
|
|
|
const results: CaseResult[] = [];
|
|
|
let pass = 0;
|
|
|
|
|
|
for (const pair of pairs) {
|
|
|
console.log(`\n[probe:human-doc] ${pair.id} ${pair.route}`);
|
|
|
const outcome = await runSystemQuote(pair);
|
|
|
results.push(outcome);
|
|
|
if (outcome.ok) {
|
|
|
pass += 1;
|
|
|
const lowest = outcome.quotes?.find(
|
|
|
(q) => q.serviceLevel === "standard" && q.rateOption === "lowest",
|
|
|
);
|
|
|
console.log(
|
|
|
`[PASS] quote=${outcome.quoteId} tiers=${outcome.tierCount} source=${outcome.sourceType} ${fmtMs(outcome.durationMs)} lowest=${lowest?.rawTotal ?? "—"}`,
|
|
|
);
|
|
|
} else {
|
|
|
console.log(`[FAIL] ${outcome.error ?? "unknown"} (${fmtMs(outcome.durationMs)})`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const report = {
|
|
|
runId,
|
|
|
mode: "system-api",
|
|
|
sourceDoc: "docs/美国跨州货物运输报价汇总_edited3.docx",
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
baseUrl: BASE,
|
|
|
summary: { total: pairs.length, pass, fail: pairs.length - pass },
|
|
|
verdict: pass === pairs.length ? "PASS" : "FAIL",
|
|
|
env: {
|
|
|
quoteStrategy: "direct-first-widget-fallback",
|
|
|
RPA_ADDRESS_MODE: process.env.RPA_ADDRESS_MODE ?? "",
|
|
|
workerHealthy: workerOk,
|
|
|
},
|
|
|
results,
|
|
|
};
|
|
|
|
|
|
const outDir = join(process.cwd(), ".rpa", "human-doc-system-probe");
|
|
|
if (!existsSync(outDir)) {
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
}
|
|
|
const outPath = join(outDir, `system-${runId}.json`);
|
|
|
writeFileSync(outPath, JSON.stringify(report, null, 2), "utf8");
|
|
|
|
|
|
console.log(`\n[probe:human-doc] ${pass}/${pairs.length} 通过 | 报告: ${outPath}`);
|
|
|
process.exit(pass === pairs.length ? 0 : 1);
|
|
|
}
|
|
|
|
|
|
main().catch((e) => {
|
|
|
console.error(e);
|
|
|
process.exit(1);
|
|
|
});
|