You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

229 lines
6.6 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;
}
}