import { RpaError } from "@/modules/rpa/errors"; import { clickFirstVisibleFromSpecs, getSelectorSpecs, } from "@/lib/rpa/selector-specs"; import type { IRPAContext } from "@/workers/rpa/kernel/context-validator"; import { assertRPAContext, } from "@/workers/rpa/kernel/context-validator"; import { FILL_CARGO_FIELD, PROBE_CARGO_UI, WAIT_CARGO_INTERACTIVE, } from "@/workers/rpa/kernel/browser-scripts"; import { buildCargoProbe } from "@/workers/rpa/kernel/normalize"; const CARGO_FIELD_RE = /托盘|pallet|长度|length|宽度|width|高度|height|重量|weight/i; const CARGO_READY_RE = /托盘|pallet|重量|weight/i; const CARGO_INTERACTIVE_WAIT_MS = 12_000; export type AddressPhase = "idle" | "pickup" | "delivery" | "committed"; export type CargoUiPhase = | "interactive" | "hydrating" | "no_cargo_inputs" | "widget_missing"; export type CargoFieldSnapshot = { placeholder: string; ariaLabel: string | null; }; export type CargoBlockedField = CargoFieldSnapshot & { blockers: string[]; }; export type CargoStateEvidence = { phase: CargoUiPhase; mountedCargoFields: CargoFieldSnapshot[]; interactiveCargoFields: CargoFieldSnapshot[]; blockedCargoFields: CargoBlockedField[]; }; export type CargoStateProbe = { ready: boolean; evidence: CargoStateEvidence; }; const WIDGET_MISSING_EVIDENCE: CargoStateEvidence = { phase: "widget_missing", mountedCargoFields: [], interactiveCargoFields: [], blockedCargoFields: [], }; export function isCargoStateReady(evidence: CargoStateEvidence): boolean { return evidence.interactiveCargoFields.some((field) => CARGO_READY_RE.test(`${field.placeholder} ${field.ariaLabel ?? ""}`), ); } /** 地址 / 货物 UI 阶段(DOM 填表专用;报价与 StateEngine 解耦) */ export class StateEngine { addressPhase: AddressPhase = "idle"; axelPlaceLocations: Partial< Record<"pickup" | "delivery", Record> > = {}; cargoSectionTriggered = false; lastCargoProbe: CargoStateProbe | null = null; constructor(private readonly ctx: IRPAContext) { assertRPAContext(ctx, "StateEngine.constructor", { allowMissingState: true, }); } markAddressSide(side: "pickup" | "delivery"): void { this.addressPhase = side; } markAddressCommitted(): void { this.addressPhase = "committed"; } async settleAddressEditing(): Promise { const widget = this.ctx.selectors.widget(this.ctx.page); const count = await widget.count().catch(() => 0); if (count === 0) { throw new RpaError( "STRUCT_CHANGE", "RPA_SELECTOR_QUOTE_WIDGET 未匹配节点,无法 settle", { retryable: false }, ); } await widget .click({ position: { x: 12, y: 12 }, timeout: 4_000 }) .catch(() => undefined); await this.ctx.page.waitForTimeout(200); this.markAddressCommitted(); } async triggerCargoSection(): Promise { for (const label of ["货运详情", "Freight details", "Shipment details"]) { const header = this.ctx.page .locator("div") .filter({ hasText: label }) .nth(4); if (await header.isVisible().catch(() => false)) { await header.click({ timeout: 8_000 }); this.cargoSectionTriggered = true; await this.ctx.page.waitForTimeout(400); return; } } const widget = this.ctx.selectors.widget(this.ctx.page); for (const label of ["货运详情", "Freight details", "Shipment details"]) { const loc = widget.getByText(label, { exact: false }).first(); if (await loc.isVisible().catch(() => false)) { await loc.click({ timeout: 8_000 }); this.cargoSectionTriggered = true; await this.ctx.page.waitForTimeout(400); return; } } for (const label of [ "What are you shipping", "What are you shipping?", "你在运送什么", ]) { const loc = widget.getByText(label, { exact: false }).first(); if (await loc.isVisible().catch(() => false)) { await loc.click({ timeout: 8_000 }); this.cargoSectionTriggered = true; await this.ctx.page.waitForTimeout(500); return; } } const summary = widget.locator("text=/\\d+\\s*Pallet/i").first(); if (await summary.isVisible().catch(() => false)) { await summary.click({ timeout: 8_000 }); this.cargoSectionTriggered = true; await this.ctx.page.waitForTimeout(500); return; } const clicked = await clickFirstVisibleFromSpecs( this.ctx.page, getSelectorSpecs("RPA_SELECTOR_CARGO_SECTION"), ); if (clicked) { this.cargoSectionTriggered = true; await this.ctx.page.waitForTimeout(500); } } async probeCargo(): Promise { const widget = this.ctx.selectors.widget(this.ctx.page); const count = await widget.count().catch(() => 0); if (count === 0) { const probe = { ready: false, evidence: WIDGET_MISSING_EVIDENCE }; this.lastCargoProbe = probe; return probe; } const raw = await this.ctx.exec.evaluateOnLocator( widget, PROBE_CARGO_UI, { cargoFieldReSource: CARGO_FIELD_RE.source }, ); const probe = buildCargoProbe(raw, isCargoStateReady); this.lastCargoProbe = probe; return probe; } async waitCargoInteractive(): Promise { const widgetSelector = this.ctx.selectors.resolve( "RPA_SELECTOR_QUOTE_WIDGET", ); try { await this.ctx.exec.waitForBrowserFunction( WAIT_CARGO_INTERACTIVE, { selector: widgetSelector, cargoReadyReSource: CARGO_READY_RE.source, }, CARGO_INTERACTIVE_WAIT_MS, ); } catch { const probe = await this.probeCargo(); throw new RpaError( "STRUCT_CHANGE", `cargo hydration 未完成:${JSON.stringify(probe.evidence)}`, { retryable: true }, ); } } async assertCargoReady(): Promise { await this.waitCargoInteractive(); const probe = await this.probeCargo(); if (!probe.ready) { throw new RpaError( "STRUCT_CHANGE", `cargo interactive 未就绪:${JSON.stringify(probe.evidence)}`, { retryable: true }, ); } return probe; } async fillCargoField(fieldReSource: string, value: string): Promise { const widget = this.ctx.selectors.widget(this.ctx.page); const count = await widget.count().catch(() => 0); if (count === 0) { return false; } const raw = await this.ctx.exec.evaluateOnLocator( widget, FILL_CARGO_FIELD, { fieldRe: fieldReSource, val: value, }, ); return raw === true; } }