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.

254 lines
7.4 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 {
clickFirstVisibleFromSpecs,
getSelectorSpecs,
} from "@/lib/rpa/selector-specs";
import { pageLocator } from "@/lib/rpa/locator";
import { RpaError } from "@/modules/rpa/errors";
import {
expandCargoEditor,
isCargoFieldsVisible,
reopenInlineCargoStep,
waitForCargoEditorOpen,
} from "@/workers/rpa/cargo-editor";
import {
probeCargoCommitState,
type ExpectedCargoCommit,
} from "@/workers/rpa/cargo-lock";
import {
probeWeightMatchesExpected,
type ExpectedCargoWeight,
} from "@/workers/rpa/cargo-weight";
const POLL_MS = 250;
const MAX_REMOVE_ROUNDS = 12;
const FREIGHT_SUMMARY_RE =
/\d+\s*(?:pallet|托盘)s?|\d+W,\s*x\s*\d+L|(\d{2,5})Lbs|\b\d{2,5}\s*(?:lbs?|kg|公斤)\b/i;
const REMOVE_BUTTON_NAME_RE =
/remove|delete|clear|trash|删除|移除|清除/i;
const ADD_FREIGHT_NAME_RE =
/ADD ITEM|Add item|Add freight|Add Freight|添加货物|添加货品|新增/i;
function hasFreightSummary(text: string): boolean {
return FREIGHT_SUMMARY_RE.test(text);
}
/** 摘要是否仍含货物行(用于判断删除是否完成) */
export function widgetHasFreightSummary(widgetTextPreview: string): boolean {
return hasFreightSummary(widgetTextPreview);
}
async function clickFirstRemoveInWidget(
page: Page,
widgetSelector: string,
): Promise<boolean> {
const fromEnv = await clickFirstVisibleFromSpecs(
page,
getSelectorSpecs("RPA_SELECTOR_FREIGHT_REMOVE"),
);
if (fromEnv) {
await page.waitForTimeout(POLL_MS);
return true;
}
const widget = pageLocator(page, widgetSelector);
const scoped = [
widget.locator('button[aria-label*="remove" i]').first(),
widget.locator('button[aria-label*="delete" i]').first(),
widget.locator('button[aria-label*="Remove" i]').first(),
widget.locator('button[aria-label*="Delete" i]').first(),
widget.getByRole("button", { name: REMOVE_BUTTON_NAME_RE }).first(),
];
for (const loc of scoped) {
if (!(await loc.isVisible().catch(() => false))) {
continue;
}
await loc.click({ timeout: 5_000, force: true }).catch(() => undefined);
await page.waitForTimeout(POLL_MS);
return true;
}
const clicked = await page.evaluate((sel) => {
const root = document.querySelector(sel);
if (!root) {
return false;
}
const buttons = root.querySelectorAll<HTMLButtonElement>("button");
for (const btn of buttons) {
const label = (
btn.getAttribute("aria-label") ??
btn.innerText ??
btn.title ??
""
).trim();
if (!/remove|delete|trash|删除|移除/i.test(label)) {
continue;
}
if (btn.offsetParent === null || btn.disabled) {
continue;
}
btn.click();
return true;
}
return false;
}, widgetSelector);
if (clicked) {
await page.waitForTimeout(POLL_MS);
}
return clicked;
}
/**
* 删除 widget 内所有已存在货物行(展开 editor 后逐条点 Remove
* @returns 点击删除次数
*/
export async function removeAllFreightRows(
page: Page,
widgetSelector: string,
): Promise<number> {
let removed = 0;
for (let round = 0; round < MAX_REMOVE_ROUNDS; round += 1) {
const probe = await probeCargoCommitState(page, widgetSelector);
if (!hasFreightSummary(probe.widgetTextPreview)) {
console.log(`[rpa] cargo-reset: 无残留货物摘要removed=${removed}`);
return removed;
}
if (!(await isCargoFieldsVisible(page, widgetSelector))) {
await expandCargoEditor(page, widgetSelector).catch(() => undefined);
await reopenInlineCargoStep(page, widgetSelector).catch(() => undefined);
}
const clicked = await clickFirstRemoveInWidget(page, widgetSelector);
if (!clicked) {
const widget = pageLocator(page, widgetSelector);
const summary = widget
.getByText(/\d+W,\s*x\s*\d+L|\d{2,5}Lbs|\d+\s*(?:pallet|托盘)s?/i)
.first();
if (await summary.isVisible().catch(() => false)) {
await summary.click({ timeout: 3_000, force: true }).catch(() => undefined);
await page.waitForTimeout(POLL_MS);
if (await clickFirstRemoveInWidget(page, widgetSelector)) {
removed += 1;
continue;
}
}
break;
}
removed += 1;
}
const after = await probeCargoCommitState(page, widgetSelector);
if (hasFreightSummary(after.widgetTextPreview)) {
throw new RpaError(
"CARGO_RESET_FAILED",
`无法删除 MotherShip 残留货物行preview=${after.widgetTextPreview.slice(0, 120)}`,
{ retryable: true },
);
}
console.log(`[rpa] cargo-reset: 已删除 ${removed} 条货物行`);
return removed;
}
async function widgetHasEditableWeightField(
page: Page,
widgetSelector: string,
): Promise<boolean> {
return page.evaluate((sel) => {
const widget = document.querySelector(sel);
if (!widget) {
return false;
}
for (const inp of widget.querySelectorAll<HTMLInputElement>("input")) {
if (inp.offsetParent === null || inp.disabled) {
continue;
}
const ph = inp.placeholder ?? "";
if (/weight|重量|lbs|lb|kg|公斤|磅/i.test(ph)) {
return true;
}
}
return false;
}, widgetSelector);
}
/** 点击 ADD ITEM / Add Freight进入空白货物编辑态 */
export async function clickAddFreightItem(
page: Page,
widgetSelector: string,
): Promise<void> {
const fromEnv = await clickFirstVisibleFromSpecs(
page,
getSelectorSpecs("RPA_SELECTOR_FREIGHT_ADD"),
);
if (fromEnv) {
await page.waitForTimeout(POLL_MS);
return;
}
const widget = pageLocator(page, widgetSelector);
const triggers = [
widget.getByRole("button", { name: ADD_FREIGHT_NAME_RE }).first(),
page.getByRole("button", { name: /^ADD ITEM$/i }).first(),
page.getByText(/^ADD ITEM$/i).first(),
];
for (const loc of triggers) {
if (!(await loc.isVisible().catch(() => false))) {
continue;
}
await loc.click({ timeout: 8_000, force: true });
await page.waitForTimeout(POLL_MS);
console.log("[rpa] cargo-reset: 已点击 Add Freight / ADD ITEM");
return;
}
if (
(await isCargoFieldsVisible(page, widgetSelector)) &&
(await widgetHasEditableWeightField(page, widgetSelector))
) {
return;
}
await expandCargoEditor(page, widgetSelector);
await waitForCargoEditorOpen(page, widgetSelector);
}
/** 提交前断言widget 摘要托盘数/重量与请求一致,不符立即 fail-fast */
export async function assertWidgetCargoSummary(
page: Page,
widgetSelector: string,
expected: ExpectedCargoCommit,
): Promise<void> {
const probe = await probeCargoCommitState(page, widgetSelector);
if (
expected.palletCount !== undefined &&
probe.internalPalletCount !== null &&
probe.internalPalletCount !== expected.palletCount
) {
throw new RpaError(
"CARGO_WEIGHT_MISMATCH",
`货物托盘数与请求不一致widget=${probe.internalPalletCount} expected=${expected.palletCount}`,
{ retryable: true },
);
}
if (expected.weight && !probeWeightMatchesExpected(probe, expected.weight)) {
throw new RpaError(
"CARGO_WEIGHT_MISMATCH",
`货物重量与请求不一致widget=${probe.internalWeight ?? probe.widgetTextPreview.slice(0, 80)} expected=${expected.weight.weightLbPerPallet}lb/托 × ${expected.weight.palletCount}托)`,
{ retryable: true },
);
}
}
export type { ExpectedCargoWeight };