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 { 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("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 { 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 { return page.evaluate((sel) => { const widget = document.querySelector(sel); if (!widget) { return false; } for (const inp of widget.querySelectorAll("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 { 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 { 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 };