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.

666 lines
20 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 type { Page } from "playwright";
import { pageLocator } from "@/lib/rpa/locator";
import { RpaError } from "@/modules/rpa/errors";
import { isCargoEditorOpen, isCargoFieldsVisible } from "@/workers/rpa/cargo-editor";
import {
type ExpectedCargoWeight,
probeWeightMatchesExpected,
} from "@/workers/rpa/cargo-weight";
import { isAddressesVisuallyCompleteForCargo } from "@/workers/rpa/address-commit";
import { dismissCookieConsent } from "@/workers/rpa/page-prep";
const POLL_MS = 200;
const COMMIT_WAIT_MS = 12_000;
const ADDRESS_SETTLE_MS = 8_000;
const CARGO_PLACEHOLDER_RE =
/pallet|weight|length|width|height|托盘|重量|长度|宽度|高度/i;
export type CargoCommitProbe = {
hasSummaryChip: boolean;
visibleEditableCargoInputs: number;
editorOpen: boolean;
internalPalletCount: number | null;
internalWeight: number | null;
widgetTextPreview: string;
};
/** 货物 committed 校验期望(单托 weightLb + palletCount */
export type ExpectedCargoCommit = {
palletCount?: number;
weight?: ExpectedCargoWeight;
};
const INVALID_WEIGHT_SUMMARY_RE = /-\s*(?:Lbs|kg|公斤)\b/i;
function parseWeightFromSummary(text: string): number | null {
const patterns = [
/x\s*(\d{2,5})\s*Lbs/i,
/(\d{2,5})Lbs/i,
/\b(\d{2,5})\s*(?:lbs?|kg|公斤)\b/i,
];
for (const re of patterns) {
const m = text.match(re);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) {
return n;
}
}
}
return null;
}
export function hasValidWeightInSummary(text: string): boolean {
if (INVALID_WEIGHT_SUMMARY_RE.test(text)) {
return false;
}
return parseWeightFromSummary(text) !== null;
}
function cargoValuesMatchExpected(
probe: CargoCommitProbe,
expected?: ExpectedCargoCommit,
): boolean {
if (!expected) {
return true;
}
if (
expected.palletCount !== undefined &&
probe.internalPalletCount !== null &&
probe.internalPalletCount !== expected.palletCount
) {
return false;
}
if (expected.weight && !probeWeightMatchesExpected(probe, expected.weight)) {
return false;
}
return true;
}
/** 纯函数:是否已达 committed非仅 UI 可见) */
export function evaluateCargoCommitted(
probe: CargoCommitProbe,
expected?: ExpectedCargoCommit | number,
): boolean {
const normalized: ExpectedCargoCommit | undefined =
typeof expected === "number" ? { palletCount: expected } : expected;
const summaryComplete =
probe.hasSummaryChip &&
!INVALID_WEIGHT_SUMMARY_RE.test(probe.widgetTextPreview) &&
hasValidWeightInSummary(probe.widgetTextPreview) &&
(/\d+W,\s*x\s*\d+L/i.test(probe.widgetTextPreview) ||
(probe.internalPalletCount !== null && probe.internalPalletCount > 0));
if (summaryComplete) {
if (
normalized?.palletCount !== undefined &&
probe.internalPalletCount !== null &&
probe.internalPalletCount !== normalized.palletCount
) {
return false;
}
if (normalized?.weight && !probeWeightMatchesExpected(probe, normalized.weight)) {
return false;
}
if (normalized?.palletCount !== undefined && probe.internalPalletCount !== null) {
return probe.internalPalletCount === normalized.palletCount;
}
return cargoValuesMatchExpected(probe, normalized);
}
if (probe.visibleEditableCargoInputs > 0) {
return false;
}
if (probe.editorOpen && probe.visibleEditableCargoInputs > 0) {
return false;
}
if (!probe.hasSummaryChip) {
return false;
}
if (INVALID_WEIGHT_SUMMARY_RE.test(probe.widgetTextPreview)) {
return false;
}
const palletOk =
probe.internalPalletCount !== null && probe.internalPalletCount > 0;
const weightOk =
probe.internalWeight !== null && probe.internalWeight > 0;
const weightSummaryOk = hasValidWeightInSummary(probe.widgetTextPreview);
const dimOk = /\d+W,\s*x\s*\d+L/i.test(probe.widgetTextPreview);
if (!palletOk && !dimOk) {
return false;
}
if (!weightOk && !weightSummaryOk) {
return false;
}
if (normalized?.palletCount !== undefined && palletOk) {
if (probe.internalPalletCount !== normalized.palletCount) {
return false;
}
}
if (normalized?.weight && !probeWeightMatchesExpected(probe, normalized.weight)) {
return false;
}
return cargoValuesMatchExpected(probe, normalized);
}
export async function probeCargoCommitState(
page: Page,
widgetSelector: string,
): Promise<CargoCommitProbe> {
return page.evaluate(
({ sel, cargoPhReSource }) => {
const widget = document.querySelector(sel);
if (!widget) {
return {
hasSummaryChip: false,
visibleEditableCargoInputs: 0,
editorOpen: false,
internalPalletCount: null,
internalWeight: null,
widgetTextPreview: "",
};
}
const cargoPhRe = new RegExp(cargoPhReSource, "i");
const innerText = (
(widget as HTMLElement).innerText ??
widget.textContent ??
""
)
.replace(/\s+/g, " ")
.trim();
const hasSummaryChip =
/\d+\s*托盘s?/i.test(innerText) ||
/\d+\s*pallets?/i.test(innerText) ||
/\d+W,\s*x\s*\d+L/i.test(innerText) ||
/\b\d{2,5}\s*(lbs?|kg|公斤)\b/i.test(innerText);
let visibleEditableCargoInputs = 0;
let internalPalletCount: number | null = null;
let internalWeight: number | null = null;
const inputs = widget.querySelectorAll<HTMLInputElement>("input");
for (const inp of inputs) {
const ph = inp.placeholder ?? "";
if (!cargoPhRe.test(ph)) {
continue;
}
const val = inp.value?.trim() ?? "";
if (/托盘|pallet/i.test(ph) && val) {
const n = Number(val);
if (Number.isFinite(n) && n > 0) {
internalPalletCount = n;
}
}
if (/weight|重量|公斤|lbs|kg/i.test(ph) && val) {
const n = Number(val);
if (Number.isFinite(n) && n > 0) {
internalWeight = n;
}
}
const visible = inp.offsetParent !== null;
const editable =
visible &&
!inp.disabled &&
inp.readOnly === false &&
inp.getAttribute("aria-readonly") !== "true";
if (editable) {
visibleEditableCargoInputs += 1;
}
}
const editorOpen = Boolean(
document.querySelector(
'[role="dialog"][aria-modal="true"], [aria-modal="true"][data-state="open"]',
),
);
return {
hasSummaryChip,
visibleEditableCargoInputs,
editorOpen,
internalPalletCount:
internalPalletCount ??
(() => {
const m = innerText.match(/(\d+)\s*(?:pallet|托盘)s?/i);
return m ? Number(m[1]) : null;
})(),
internalWeight:
internalWeight ??
(() => {
const patterns = [
/x\s*(\d{2,5})\s*Lbs/i,
/(\d{2,5})Lbs/i,
/\b(\d{2,5})\s*(?:lbs?|kg|公斤)\b/i,
];
for (const re of patterns) {
const m = innerText.match(re);
if (m) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0) {
return n;
}
}
}
return null;
})(),
widgetTextPreview: innerText.slice(0, 220),
};
},
{ sel: widgetSelector, cargoPhReSource: CARGO_PLACEHOLDER_RE.source },
);
}
async function landingPopoverHasCargo(page: Page): Promise<boolean> {
return page.evaluate(() => {
const pallet = document.querySelector<HTMLInputElement>(
'input[placeholder*="How many pallets" i]',
);
const weight = document.querySelector<HTMLInputElement>(
'input[placeholder*="Weight" i]',
);
return Boolean(pallet?.value?.trim() && weight?.value?.trim());
});
}
export async function isCargoCommittedState(
page: Page,
widgetSelector: string,
expected?: ExpectedCargoCommit | number,
): Promise<boolean> {
const normalized: ExpectedCargoCommit | undefined =
typeof expected === "number" ? { palletCount: expected } : expected;
if (normalized && (await landingPopoverMatchesExpected(page, normalized))) {
return true;
}
if (!normalized && (await landingPopoverHasCargo(page))) {
return true;
}
const editorOpen = await isCargoEditorOpen(page);
const probe = await probeCargoCommitState(page, widgetSelector);
return evaluateCargoCommitted(
{ ...probe, editorOpen: probe.editorOpen || editorOpen },
expected,
);
}
/** 地址搜索框是否仍可编辑 */
export async function isAddressSearchEditable(
page: Page,
widgetSelector: string,
): Promise<boolean> {
return page.evaluate((sel) => {
const widget = document.querySelector(sel);
if (!widget) {
return true;
}
const inputs = widget.querySelectorAll<HTMLInputElement>("input");
for (const inp of inputs) {
const ph = inp.placeholder ?? "";
if (!/search|搜索/i.test(ph)) {
continue;
}
const visible = inp.offsetParent !== null;
const editable =
!inp.disabled &&
inp.readOnly === false &&
inp.getAttribute("aria-readonly") !== "true";
if (visible && editable) {
return true;
}
}
return false;
}, widgetSelector);
}
/** 双地址必须已写入 widget进入货物步前 */
export async function assertBothAddressesLocked(
page: Page,
widgetSelector: string,
): Promise<void> {
if (await isAddressesVisuallyCompleteForCargo(page, widgetSelector)) {
console.log("[rpa] assertBothAddressesLocked: 视觉已确认双地址,跳过网络闸门");
return;
}
const deadline = Date.now() + ADDRESS_SETTLE_MS;
while (Date.now() < deadline) {
if (await isAddressesLockedForSubmit(page, widgetSelector)) {
return;
}
await page.waitForTimeout(POLL_MS);
}
const text = await page
.locator(widgetSelector)
.innerText()
.catch(() => "");
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`双地址未锁定无法进入货物步preview=${text.slice(0, 120)}`,
{ retryable: true },
);
}
/** 纯函数:双地址是否已锁定(供单测) */
export function evaluateAddressesLockedForSubmit(
widgetText: string,
addressSearchEditable: boolean,
): boolean {
if (addressSearchEditable) {
return false;
}
const normalized = widgetText.replace(/\s+/g, " ");
const zipCount = (normalized.match(/\b\d{5}(?:-\d{4})?\b/g) ?? []).length;
const stateCount = (normalized.match(/,\s*[A-Z]{2}\b/g) ?? []).length;
const streetMarkers = normalized.match(/\d{3,5}\s+\S+/g) ?? [];
return stateCount >= 2 || zipCount >= 2 || streetMarkers.length >= 2;
}
/** submit 前地址是否已锁定(必须双地址证据,禁止单州码弱放行) */
export async function isAddressesLockedForSubmit(
page: Page,
widgetSelector: string,
): Promise<boolean> {
const text = await page
.locator(widgetSelector)
.innerText()
.catch(() => "");
const addressSearchEditable = await isAddressSearchEditable(
page,
widgetSelector,
);
return evaluateAddressesLockedForSubmit(text, addressSearchEditable);
}
/** 收起内联货物步以便 submit不点「货运详情」避免重置向导 */
export async function collapseInlineCargoForSubmit(
page: Page,
widgetSelector: string,
): Promise<void> {
const widget = pageLocator(page, widgetSelector);
for (const pattern of [/\d+W,\s*x\s*\d+L/i, /\d+\s*托盘s?/i, /\d+\s*pallets?/i]) {
const summary = widget.getByText(pattern).first();
if (await summary.isVisible().catch(() => false)) {
await summary
.click({ timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(350);
break;
}
}
if (await isCargoFieldsVisible(page, widgetSelector)) {
await page.keyboard.press("Escape").catch(() => undefined);
await widget
.click({ position: { x: 300, y: 60 }, timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(300);
}
}
/** 尝试提交内联货物步(不点击地址 chip / 步骤标签,避免重置向导) */
export async function attemptCargoCommit(
page: Page,
widgetSelector: string,
): Promise<void> {
const widget = pageLocator(page, widgetSelector);
await page.keyboard.press("Tab").catch(() => undefined);
await page.keyboard.press("Enter").catch(() => undefined);
await page.waitForTimeout(250);
const summaryPatterns = [
/\d+\s*托盘s?/i,
/\d+\s*pallets?/i,
/\d+W,\s*x\s*\d+L/i,
];
for (const pattern of summaryPatterns) {
const summary = widget.getByText(pattern).first();
if (await summary.isVisible().catch(() => false)) {
await summary
.click({ timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(400);
return;
}
}
await page.keyboard.press("Escape").catch(() => undefined);
await widget
.click({ position: { x: 280, y: 220 }, timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(300);
}
async function landingPopoverMatchesExpected(
page: Page,
expected: ExpectedCargoCommit,
): Promise<boolean> {
const state = await page.evaluate(() => {
const pallet = document.querySelector<HTMLInputElement>(
'input[placeholder*="How many pallets" i]',
);
const weight = document.querySelector<HTMLInputElement>(
'input[placeholder*="Weight" i]',
);
return {
pallet: pallet?.value?.trim() ?? "",
weight: weight?.value?.trim() ?? "",
};
});
if (!state.pallet || !state.weight) {
return false;
}
if (
expected.palletCount !== undefined &&
Number(state.pallet) !== expected.palletCount
) {
return false;
}
if (expected.weight) {
const actual = Number(state.weight);
const target = Number(expected.weight.displayValue);
if (Number.isFinite(actual) && Number.isFinite(target) && actual !== target) {
return false;
}
}
return true;
}
export async function waitForCargoCommittedState(
page: Page,
widgetSelector: string,
expected?: ExpectedCargoCommit | number,
): Promise<CargoCommitProbe> {
const normalized: ExpectedCargoCommit | undefined =
typeof expected === "number" ? { palletCount: expected } : expected;
if (normalized && (await landingPopoverMatchesExpected(page, normalized))) {
console.log("[rpa] cargo-commit: oklanding Freight details 弹层已填)");
return probeCargoCommitState(page, widgetSelector);
}
const deadline = Date.now() + COMMIT_WAIT_MS;
let last: CargoCommitProbe | null = null;
while (Date.now() < deadline) {
const probe = await probeCargoCommitState(page, widgetSelector);
const editorOpen = await isCargoEditorOpen(page);
const merged = { ...probe, editorOpen: probe.editorOpen || editorOpen };
last = merged;
if (normalized && (await landingPopoverMatchesExpected(page, normalized))) {
console.log("[rpa] cargo-commit: oklanding Freight details 弹层已填)");
return merged;
}
if (evaluateCargoCommitted(merged, normalized)) {
console.log(
`[rpa] cargo-commit: ok pallet=${merged.internalPalletCount ?? "n/a"} weight=${merged.internalWeight ?? "n/a"} editable=${merged.visibleEditableCargoInputs}`,
);
return merged;
}
await page.waitForTimeout(POLL_MS);
}
const failed = last ?? (await probeCargoCommitState(page, widgetSelector));
console.log(`[rpa] cargo-commit: FAIL ${JSON.stringify(failed)}`);
const weightHint = normalized?.weight
? ` expectedWeight=${normalized.weight.weightLbPerPallet}lb/pallet`
: "";
throw new RpaError(
normalized?.weight &&
failed.hasSummaryChip &&
!probeWeightMatchesExpected(failed, normalized.weight)
? "CARGO_WEIGHT_MISMATCH"
: "CARGO_NOT_COMMITTED",
normalized?.weight &&
failed.hasSummaryChip &&
!probeWeightMatchesExpected(failed, normalized.weight)
? `货物重量与请求不一致widget=${failed.internalWeight ?? failed.widgetTextPreview.slice(0, 80)}${weightHint}`
: `货物未进入 committed 态summary=${failed.hasSummaryChip} editable=${failed.visibleEditableCargoInputs} pallet=${failed.internalPalletCount}`,
{ retryable: true },
);
}
export async function assertCargoCommittedState(
page: Page,
widgetSelector: string,
expected?: ExpectedCargoCommit | number,
): Promise<void> {
if (await isCargoCommittedState(page, widgetSelector, expected)) {
return;
}
const probe = await probeCargoCommitState(page, widgetSelector);
throw new RpaError(
"CARGO_NOT_COMMITTED",
`submit 前货物未 committed${JSON.stringify(probe)}`,
{ retryable: true },
);
}
/** 收起地址编辑态,确保进入货物步前无未锁定 street 输入 */
export async function settleAddressFieldsForCargo(
page: Page,
widgetSelector: string,
): Promise<void> {
await dismissCookieConsent(page);
const widget = pageLocator(page, widgetSelector);
const deadline = Date.now() + ADDRESS_SETTLE_MS;
while (Date.now() < deadline) {
if (await isAddressesVisuallyCompleteForCargo(page, widgetSelector)) {
console.log("[rpa] settleAddressFieldsForCargo: 视觉双地址已就绪,进入货物步");
return;
}
if (await isAddressesLockedForSubmit(page, widgetSelector)) {
return;
}
const hasEditableStreet = await page.evaluate((sel) => {
const root = document.querySelector(sel);
if (!root) {
return true;
}
const inputs = root.querySelectorAll<HTMLInputElement>("input");
for (const inp of inputs) {
const ph = inp.placeholder ?? "";
if (!/search|搜索/i.test(ph)) {
continue;
}
const visible = inp.offsetParent !== null;
const editable =
!inp.disabled &&
inp.readOnly === false &&
inp.getAttribute("aria-readonly") !== "true";
if (visible && editable) {
return true;
}
}
return false;
}, widgetSelector);
if (!hasEditableStreet) {
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await widget
.click({ position: { x: 24, y: 24 }, timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(350);
}
throw new RpaError(
"STRUCT_CHANGE",
"地址编辑态未收起,无法进入货物步",
{ retryable: true },
);
}
/** @deprecated 使用 isCargoCommittedState */
export async function isCargoSummaryLocked(
page: Page,
widgetSelector: string,
expectedPalletCount?: number,
): Promise<boolean> {
return isCargoCommittedState(page, widgetSelector, expectedPalletCount);
}
/** 关闭 cargo popover / drawer */
export async function closeCargoEditor(
page: Page,
widgetSelector?: string,
): Promise<void> {
if (widgetSelector) {
const widget = pageLocator(page, widgetSelector);
await page.keyboard.press("Escape").catch(() => undefined);
await widget
.click({ position: { x: 180, y: 100 }, timeout: 3_000, force: true })
.catch(() => undefined);
await page.waitForTimeout(400);
}
if (!(await isCargoEditorOpen(page))) {
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(400);
if (!(await isCargoEditorOpen(page))) {
return;
}
const dismissTargets = [
page.locator("#react-quoter-landing-plugin").first(),
page.getByRole("button", { name: /done|save|apply|确认|完成/i }).first(),
];
for (const loc of dismissTargets) {
if (!(await loc.isVisible().catch(() => false))) {
continue;
}
await loc.click({ timeout: 3_000, force: true }).catch(() => undefined);
await page.waitForTimeout(400);
if (!(await isCargoEditorOpen(page))) {
return;
}
}
await page.mouse.click(12, 12).catch(() => undefined);
await page.waitForTimeout(400);
}
/** @deprecated 使用 waitForCargoCommittedState */
export async function waitForCargoSummaryLocked(
page: Page,
widgetSelector: string,
expectedPalletCount?: number,
): Promise<void> {
await waitForCargoCommittedState(page, widgetSelector, expectedPalletCount);
}