|
|
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<string, unknown>>
|
|
|
> = {};
|
|
|
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<void> {
|
|
|
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<void> {
|
|
|
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<CargoStateProbe> {
|
|
|
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<CargoStateEvidence>(
|
|
|
widget,
|
|
|
PROBE_CARGO_UI,
|
|
|
{ cargoFieldReSource: CARGO_FIELD_RE.source },
|
|
|
);
|
|
|
const probe = buildCargoProbe(raw, isCargoStateReady);
|
|
|
this.lastCargoProbe = probe;
|
|
|
return probe;
|
|
|
}
|
|
|
|
|
|
async waitCargoInteractive(): Promise<void> {
|
|
|
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<CargoStateProbe> {
|
|
|
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<boolean> {
|
|
|
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<boolean>(
|
|
|
widget,
|
|
|
FILL_CARGO_FIELD,
|
|
|
{
|
|
|
fieldRe: fieldReSource,
|
|
|
val: value,
|
|
|
},
|
|
|
);
|
|
|
return raw === true;
|
|
|
}
|
|
|
}
|