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