|
|
/**
|
|
|
* 地址 commit 事件对照录证(headed 人工 vs RPA 生产路径)
|
|
|
*
|
|
|
* 目标:HAR + axel event trace → 对比缺失事件(不修 click/delay/retry)
|
|
|
*
|
|
|
* 用法:
|
|
|
* # 仅人工(headed + HAR)
|
|
|
* $env:COMPARE_MODE="manual"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts
|
|
|
*
|
|
|
* # 仅 RPA(fillAddressStep 生产路径)
|
|
|
* $env:COMPARE_MODE="rpa"; $env:COMPARE_PAIR_ID="99"; npx tsx scripts/record-address-event-compare.ts
|
|
|
*
|
|
|
* # 先后执行人工 + RPA 并 diff
|
|
|
* $env:COMPARE_MODE="both"; npx tsx scripts/record-address-event-compare.ts
|
|
|
*
|
|
|
* 人工检查点(非 TTY):
|
|
|
* $env:COMPARE_USE_FILE_TRIGGER="1"
|
|
|
* pickup 完成后: New-Item -Force .rpa\address-event-compare\trigger-pickup-done
|
|
|
* delivery 完成后: New-Item -Force .rpa\address-event-compare\trigger-delivery-done
|
|
|
*/
|
|
|
import "dotenv/config";
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import readline from "node:readline";
|
|
|
import { stdin as input, stdout as output } from "node:process";
|
|
|
import { chromium } from "playwright";
|
|
|
import { loadDevEnv } from "@/lib/dev-env";
|
|
|
import { ensureNativeInfraIfNeeded } from "@/lib/infra/ensure-native-infra";
|
|
|
import {
|
|
|
hostFetchMothershipCandidates,
|
|
|
} from "@/lib/frontend/api-client";
|
|
|
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
|
|
|
import { getPrimaryMothershipQuoteUrl } from "@/lib/rpa/env";
|
|
|
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
|
|
|
import { ensureRedisReady, resetRedisClient } from "@/lib/redis";
|
|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import {
|
|
|
AxelEventTracer,
|
|
|
diffAxelTraces,
|
|
|
normalizeAxelSequence,
|
|
|
} from "@/scripts/lib/axel-event-tracer";
|
|
|
import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step";
|
|
|
import { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
|
|
|
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
|
|
|
import {
|
|
|
drainSelectionTraces,
|
|
|
installManualSelectionListeners,
|
|
|
logManualInteractionGoldSample,
|
|
|
pickSelectionGoldInteractions,
|
|
|
recordManualCheckpoint,
|
|
|
} from "@/workers/rpa/selection-trace";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
|
|
|
loadDevEnv();
|
|
|
|
|
|
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
|
|
|
const TOKEN = "demo-host-token";
|
|
|
const CUSTOMER_ID = "CUST_001";
|
|
|
const OUT_ROOT = join(process.cwd(), ".rpa", "address-event-compare");
|
|
|
const TRIGGER_PICKUP = join(OUT_ROOT, "trigger-pickup-done");
|
|
|
const TRIGGER_DELIVERY = join(OUT_ROOT, "trigger-delivery-done");
|
|
|
|
|
|
const PAIRS: Record<
|
|
|
string,
|
|
|
{
|
|
|
label: string;
|
|
|
pickup: { street: string; city: string; state: string };
|
|
|
delivery: { street: string; city: string; state: string };
|
|
|
}
|
|
|
> = {
|
|
|
"99": {
|
|
|
label: "Key West FL → Los Angeles CA(Duval / Hollywood)",
|
|
|
pickup: { street: "Duval Street", city: "Key West", state: "FL" },
|
|
|
delivery: {
|
|
|
street: "Hollywood Boulevard",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
},
|
|
|
},
|
|
|
"01": {
|
|
|
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" },
|
|
|
},
|
|
|
};
|
|
|
|
|
|
const PAIR_ID = process.env.COMPARE_PAIR_ID ?? "99";
|
|
|
const MODE = (process.env.COMPARE_MODE ?? "both").toLowerCase();
|
|
|
|
|
|
function draftAddress(d: { street: string; city: string; state: string }) {
|
|
|
const street = d.street.trim();
|
|
|
const city = d.city.trim();
|
|
|
const state = d.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 toQuoteRequest(
|
|
|
pickup: QuoteRequest["pickup"],
|
|
|
delivery: QuoteRequest["delivery"],
|
|
|
): QuoteRequest {
|
|
|
return {
|
|
|
cargoHash: "event-compare",
|
|
|
pickup,
|
|
|
delivery,
|
|
|
weightLb: 503,
|
|
|
dimsIn: { l: 48, w: 40, h: 48 },
|
|
|
palletCount: 2,
|
|
|
cargoType: "general_freight",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function fetchConfirmedAddresses(pairId: string): Promise<{
|
|
|
pair: (typeof PAIRS)[string];
|
|
|
pickup: QuoteRequest["pickup"];
|
|
|
delivery: QuoteRequest["delivery"];
|
|
|
pickupLabel: string;
|
|
|
deliveryLabel: string;
|
|
|
}> {
|
|
|
const pair = PAIRS[pairId];
|
|
|
if (!pair) {
|
|
|
throw new Error(`未知 COMPARE_PAIR_ID=${pairId}`);
|
|
|
}
|
|
|
|
|
|
const userPickup = draftAddress(pair.pickup);
|
|
|
const userDelivery = draftAddress(pair.delivery);
|
|
|
const candRes = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
userPickup,
|
|
|
userDelivery,
|
|
|
);
|
|
|
if (candRes.code !== 0 || !candRes.data) {
|
|
|
throw new Error(`候选 API 失败: ${candRes.message}`);
|
|
|
}
|
|
|
|
|
|
const selectedPickup = candRes.data.pickup_candidates[0];
|
|
|
const selectedDelivery = candRes.data.delivery_candidates[0];
|
|
|
if (!selectedPickup || !selectedDelivery) {
|
|
|
throw new Error("候选不足");
|
|
|
}
|
|
|
|
|
|
const pickupApplied = applyMothershipCandidate(userPickup, selectedPickup);
|
|
|
const deliveryApplied = applyMothershipCandidate(userDelivery, selectedDelivery);
|
|
|
|
|
|
return {
|
|
|
pair,
|
|
|
pickupLabel: selectedPickup.display_label,
|
|
|
deliveryLabel: selectedDelivery.display_label,
|
|
|
pickup: {
|
|
|
street: pickupApplied.street,
|
|
|
city: pickupApplied.city,
|
|
|
state: pickupApplied.state,
|
|
|
zip: pickupApplied.zip ?? "",
|
|
|
placeId: userPickup.place_id,
|
|
|
formattedAddress: pickupApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: pickupApplied.mothership_option_id ?? "",
|
|
|
mothershipDisplayLabel: pickupApplied.mothership_display_label ?? "",
|
|
|
selectedFromMothership: true,
|
|
|
},
|
|
|
delivery: {
|
|
|
street: deliveryApplied.street,
|
|
|
city: deliveryApplied.city,
|
|
|
state: deliveryApplied.state,
|
|
|
zip: deliveryApplied.zip ?? "",
|
|
|
placeId: userDelivery.place_id,
|
|
|
formattedAddress: deliveryApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: deliveryApplied.mothership_option_id ?? "",
|
|
|
mothershipDisplayLabel: deliveryApplied.mothership_display_label ?? "",
|
|
|
selectedFromMothership: true,
|
|
|
},
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function waitForCheckpoint(label: string, triggerPath: string): Promise<void> {
|
|
|
const useFile =
|
|
|
process.env.COMPARE_USE_FILE_TRIGGER === "1" || !process.stdin.isTTY;
|
|
|
|
|
|
console.log(`\n── ${label} ──`);
|
|
|
if (useFile) {
|
|
|
if (existsSync(triggerPath)) {
|
|
|
rmSync(triggerPath, { force: true });
|
|
|
}
|
|
|
console.log(
|
|
|
`文件触发:完成后执行\n New-Item -Force ${triggerPath.replace(/\\/g, "\\\\")}`,
|
|
|
);
|
|
|
while (!existsSync(triggerPath)) {
|
|
|
await new Promise((r) => setTimeout(r, 400));
|
|
|
}
|
|
|
rmSync(triggerPath, { force: true });
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const rl = readline.createInterface({ input, output, terminal: true });
|
|
|
await new Promise<void>((resolve) => {
|
|
|
rl.question("完成后按 Enter 继续… ", () => {
|
|
|
rl.close();
|
|
|
resolve();
|
|
|
});
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function runManualSession(
|
|
|
runDir: string,
|
|
|
pairId: string,
|
|
|
): Promise<{ events: ReturnType<AxelEventTracer["getEvents"]>; harPath: string }> {
|
|
|
const { pair, pickupLabel, deliveryLabel } = await fetchConfirmedAddresses(pairId);
|
|
|
const entryUrl = getPrimaryMothershipQuoteUrl();
|
|
|
const harPath = join(runDir, "manual.har");
|
|
|
const tracer = new AxelEventTracer();
|
|
|
|
|
|
console.log(`\n=== 人工 headed 录证:组 ${pairId} ${pair.label} ===`);
|
|
|
console.log(`入口:${entryUrl}`);
|
|
|
console.log(`
|
|
|
操作说明(金样本:必须让脚本录到「点中了哪颗 DOM」):
|
|
|
① 提货框输入:${pair.pickup.street}, ${pair.pickup.city}, ${pair.pickup.state}
|
|
|
(不要一上来打完整酒店名;与 RPA 区分,先打出街道级联想)
|
|
|
② 等联想列表出现 → 用鼠标点「${pickupLabel}」
|
|
|
或键盘:↓ 移到目标 → 浏览器里 Enter(不是终端 Enter)
|
|
|
③ 确认提货锁定后,终端检查点 pickup 按 Enter
|
|
|
④ 派送框输入:${pair.delivery.street}, ${pair.delivery.city}, ${pair.delivery.state}
|
|
|
⑤ 点选「${deliveryLabel}」或 ↓+Enter
|
|
|
⑥ 终端检查点 delivery 按 Enter
|
|
|
|
|
|
终端 Enter 仅用于检查点;浏览器 Enter 才用于选联想。
|
|
|
录证成功标志:检查点日志出现 selectionClicks>=1 或 selectionKeys 含 Enter@INPUT
|
|
|
`);
|
|
|
|
|
|
const browser = await chromium.launch({
|
|
|
headless: false,
|
|
|
channel: process.env.RPA_BROWSER_CHANNEL as "chrome" | "msedge" | undefined,
|
|
|
slowMo: Number(process.env.RPA_SLOW_MO_MS ?? 80) || 80,
|
|
|
});
|
|
|
|
|
|
const context = await browser.newContext({
|
|
|
locale: process.env.RPA_BROWSER_LOCALE?.trim() || "en-US",
|
|
|
recordHar: { path: harPath, mode: "full", content: "embed" },
|
|
|
});
|
|
|
const page = await context.newPage();
|
|
|
tracer.attach(page);
|
|
|
page.setDefaultTimeout(120_000);
|
|
|
|
|
|
tracer.setPhase("navigate");
|
|
|
await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
|
await page.waitForTimeout(3_000);
|
|
|
await installManualSelectionListeners(page);
|
|
|
console.log(
|
|
|
"[manual] 已注入 click/keydown 监听(capture 阶段);成功点选后检查点会导出 interaction 日志",
|
|
|
);
|
|
|
|
|
|
tracer.setPhase("manual-pickup");
|
|
|
await waitForCheckpoint("检查点 pickup(人工点选完成)", TRIGGER_PICKUP);
|
|
|
const pickupForensics = await recordManualCheckpoint(page, "manual-pickup-done");
|
|
|
logManualInteractionGoldSample("pickup", pickupForensics.interactions);
|
|
|
const pickupGold = pickSelectionGoldInteractions(pickupForensics.interactions);
|
|
|
writeFileSync(
|
|
|
join(runDir, "manual-selection-pickup.json"),
|
|
|
JSON.stringify({ ...pickupForensics, gold: pickupGold }, null, 2),
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
tracer.setPhase("manual-delivery");
|
|
|
await waitForCheckpoint("检查点 delivery(人工点选完成)", TRIGGER_DELIVERY);
|
|
|
const deliveryForensics = await recordManualCheckpoint(page, "manual-delivery-done");
|
|
|
logManualInteractionGoldSample("delivery", deliveryForensics.interactions);
|
|
|
const deliveryGold = pickSelectionGoldInteractions(deliveryForensics.interactions);
|
|
|
writeFileSync(
|
|
|
join(runDir, "manual-selection-delivery.json"),
|
|
|
JSON.stringify({ ...deliveryForensics, gold: deliveryGold }, null, 2),
|
|
|
"utf8",
|
|
|
);
|
|
|
writeFileSync(
|
|
|
join(runDir, "manual-interactions-gold.json"),
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
pairId,
|
|
|
pickup: pickupGold,
|
|
|
delivery: deliveryGold,
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
await page
|
|
|
.screenshot({ path: join(runDir, "manual-final.png") })
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
tracer.detach();
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
|
|
|
if (!existsSync(harPath)) {
|
|
|
throw new Error(`HAR 未生成: ${harPath}`);
|
|
|
}
|
|
|
|
|
|
const events = [...tracer.getEvents()];
|
|
|
writeFileSync(
|
|
|
join(runDir, "manual-trace.json"),
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
mode: "manual",
|
|
|
pairId,
|
|
|
label: pair.label,
|
|
|
pickupHint: pickupLabel,
|
|
|
deliveryHint: deliveryLabel,
|
|
|
events,
|
|
|
sequence: normalizeAxelSequence(events),
|
|
|
harPath,
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
console.log(`[manual] HAR: ${harPath}`);
|
|
|
console.log(`[manual] trace: ${join(runDir, "manual-trace.json")}`);
|
|
|
console.log(`[manual] gold: ${join(runDir, "manual-interactions-gold.json")}`);
|
|
|
console.log(`[manual] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId));
|
|
|
|
|
|
return { events, harPath };
|
|
|
}
|
|
|
|
|
|
function writeSelectionTraceArtifact(runDir: string, mode: string): void {
|
|
|
const traces = drainSelectionTraces();
|
|
|
if (!traces.length) {
|
|
|
return;
|
|
|
}
|
|
|
const outPath = join(runDir, `${mode}-selection-trace.json`);
|
|
|
writeFileSync(outPath, JSON.stringify({ mode, traces }, null, 2), "utf8");
|
|
|
console.log(`[${mode}] selection-trace: ${outPath} (${traces.length} 条快照)`);
|
|
|
}
|
|
|
|
|
|
async function runRpaSession(
|
|
|
runDir: string,
|
|
|
pairId: string,
|
|
|
): Promise<ReturnType<AxelEventTracer["getEvents"]>> {
|
|
|
const { pair, pickup, delivery } = await fetchConfirmedAddresses(pairId);
|
|
|
const tracer = new AxelEventTracer();
|
|
|
|
|
|
console.log(`\n=== RPA 生产路径录证:组 ${pairId} ${pair.label} ===`);
|
|
|
console.log(`fillAddressStep(与 Worker 询价相同)`);
|
|
|
|
|
|
process.env.RPA_HEADLESS = process.env.COMPARE_RPA_HEADED === "true" ? "false" : "true";
|
|
|
|
|
|
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
|
|
|
freshCargoContext: true,
|
|
|
});
|
|
|
const ctx = RPAContext.fromSession(opened.page, opened.context);
|
|
|
tracer.attach(ctx.page);
|
|
|
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
tracer.setPhase("hydrate");
|
|
|
await waitForQuoterWidgetHydrated(ctx.page, 60_000);
|
|
|
|
|
|
tracer.setPhase("rpa-fillAddress");
|
|
|
try {
|
|
|
await fillAddressStep(ctx, toQuoteRequest(pickup, delivery));
|
|
|
} catch (error) {
|
|
|
writeSelectionTraceArtifact(runDir, "rpa");
|
|
|
const events = [...tracer.getEvents()];
|
|
|
writeFileSync(
|
|
|
join(runDir, "rpa-trace.json"),
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
mode: "rpa",
|
|
|
pairId,
|
|
|
label: pair.label,
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
events,
|
|
|
sequence: normalizeAxelSequence(events),
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
await closeBrowser().catch(() => undefined);
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
writeSelectionTraceArtifact(runDir, "rpa");
|
|
|
const events = [...tracer.getEvents()];
|
|
|
writeFileSync(
|
|
|
join(runDir, "rpa-trace.json"),
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
mode: "rpa",
|
|
|
pairId,
|
|
|
label: pair.label,
|
|
|
events,
|
|
|
sequence: normalizeAxelSequence(events),
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
await ctx.page
|
|
|
.screenshot({ path: join(runDir, "rpa-final.png") })
|
|
|
.catch(() => undefined);
|
|
|
tracer.detach();
|
|
|
await closeBrowser();
|
|
|
|
|
|
console.log(`[rpa] trace: ${join(runDir, "rpa-trace.json")}`);
|
|
|
console.log(`[rpa] place commits:`, events.filter((e) => e.kind === "place" && e.status === 200).map((e) => e.placeId));
|
|
|
|
|
|
return events;
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
ensureNativeInfraIfNeeded();
|
|
|
resetRedisClient();
|
|
|
await ensureRedisReady(12, 1_000);
|
|
|
|
|
|
const res = await fetch(BASE, { method: "GET", redirect: "manual" });
|
|
|
if (res.status <= 0) {
|
|
|
throw new Error("Next 未响应,请先 npm run dev:start");
|
|
|
}
|
|
|
if (!(await isAnyRpaWorkerHealthy())) {
|
|
|
throw new Error("RPA Worker 无心跳");
|
|
|
}
|
|
|
|
|
|
const runId = randomUUID().slice(0, 8);
|
|
|
const runDir = join(OUT_ROOT, `${PAIR_ID}-${runId}`);
|
|
|
mkdirSync(runDir, { recursive: true });
|
|
|
mkdirSync(OUT_ROOT, { recursive: true });
|
|
|
|
|
|
console.log(`录证目录:${runDir}`);
|
|
|
console.log(`COMPARE_MODE=${MODE} COMPARE_PAIR_ID=${PAIR_ID}`);
|
|
|
|
|
|
let manualEvents: ReturnType<AxelEventTracer["getEvents"]> | null = null;
|
|
|
let rpaEvents: ReturnType<AxelEventTracer["getEvents"]> | null = null;
|
|
|
|
|
|
if (MODE === "manual" || MODE === "both") {
|
|
|
const manual = await runManualSession(runDir, PAIR_ID);
|
|
|
manualEvents = [...manual.events];
|
|
|
}
|
|
|
|
|
|
if (MODE === "rpa" || MODE === "both") {
|
|
|
try {
|
|
|
rpaEvents = [...(await runRpaSession(runDir, PAIR_ID))];
|
|
|
} catch (error) {
|
|
|
console.error("[rpa] fillAddress 失败(trace 已写入):", error);
|
|
|
if (MODE === "rpa") {
|
|
|
process.exitCode = 1;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (manualEvents && rpaEvents) {
|
|
|
const diff = diffAxelTraces(manualEvents, rpaEvents);
|
|
|
const comparePath = join(runDir, "compare.json");
|
|
|
writeFileSync(
|
|
|
comparePath,
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
runId,
|
|
|
pairId: PAIR_ID,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
...diff,
|
|
|
interpretation: interpretDiff(diff),
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
console.log(`\n对照报告:${comparePath}`);
|
|
|
console.log(JSON.stringify(interpretDiff(diff), null, 2));
|
|
|
}
|
|
|
|
|
|
const summaryPath = join(runDir, "README.txt");
|
|
|
writeFileSync(
|
|
|
summaryPath,
|
|
|
[
|
|
|
`runId=${runId}`,
|
|
|
`pair=${PAIR_ID}`,
|
|
|
`mode=${MODE}`,
|
|
|
"",
|
|
|
"产物:",
|
|
|
"- manual.har + manual-trace.json(人工)",
|
|
|
"- rpa-trace.json(RPA fillAddressStep)",
|
|
|
"- compare.json(both 模式)",
|
|
|
"",
|
|
|
"下一步:仅实现 compare.json 中 missingInRpa 对应的事件,禁止 patch click/delay/retry。",
|
|
|
].join("\n"),
|
|
|
"utf8",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function interpretDiff(
|
|
|
diff: ReturnType<typeof diffAxelTraces>,
|
|
|
): Record<string, unknown> {
|
|
|
return {
|
|
|
verdict: diff.verdict,
|
|
|
manualPlaceCommits: diff.manualPlaceCommits,
|
|
|
rpaPlaceCommits: diff.rpaPlaceCommits,
|
|
|
missingInRpaCount: diff.missingInRpa.length,
|
|
|
missingInRpa: diff.missingInRpa.slice(0, 20),
|
|
|
extraInRpa: diff.extraInRpa.slice(0, 20),
|
|
|
nextAction:
|
|
|
diff.verdict === "RPA_MISSING_PLACE_COMMIT"
|
|
|
? "RPA 未触发 GET place/{id};对照 manual trace 中 pickup/delivery 的 place 事件,补缺失 selection 事件"
|
|
|
: diff.verdict === "MANUAL_NO_PLACE_COMMIT"
|
|
|
? "人工录证未捕获 place commit,请重跑 manual 并确认真实点选联想项"
|
|
|
: "查看 missingInRpa 逐行 diff",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
main().catch((error) => {
|
|
|
console.error(error);
|
|
|
process.exit(1);
|
|
|
});
|