import type { Page } from "playwright"; import { pageLocator } from "@/lib/rpa/locator"; import { RpaError } from "@/modules/rpa/errors"; import { isRpaAddressRushMode, isRpaBlindAddressFlow } from "@/lib/rpa/env"; 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 { 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("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 { return page.evaluate(() => { const pallet = document.querySelector( 'input[placeholder*="How many pallets" i]', ); const weight = document.querySelector( 'input[placeholder*="Weight" i]', ); return Boolean(pallet?.value?.trim() && weight?.value?.trim()); }); } export async function isCargoCommittedState( page: Page, widgetSelector: string, expected?: ExpectedCargoCommit | number, ): Promise { 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 { return page.evaluate((sel) => { const widget = document.querySelector(sel); if (!widget) { return true; } const inputs = widget.querySelectorAll("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 { 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) ?? []; const cityPairs = (normalized.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*,\s*[A-Z]{2}\b/g) ?? []).length; return ( stateCount >= 2 || zipCount >= 2 || streetMarkers.length >= 2 || cityPairs >= 2 ); } /** submit 前地址是否已锁定(必须双地址证据,禁止单州码弱放行) */ export async function isAddressesLockedForSubmit( page: Page, widgetSelector: string, ): Promise { 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 { 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); } } const CARGO_OVERLAY_SELECTORS = ['[role="dialog"]', '[aria-modal="true"]'] as const; /** 仅 dialog/modal 货物弹层;widget 内联 Freight details 不算弹层 */ export async function isCargoOverlayPopoverOpen(page: Page): Promise { for (const selector of CARGO_OVERLAY_SELECTORS) { const overlays = page.locator(selector); const count = await overlays.count().catch(() => 0); for (let i = 0; i < count; i++) { const overlay = overlays.nth(i); if (!(await overlay.isVisible().catch(() => false))) { continue; } const cargoInput = overlay .locator( 'input[placeholder*="pallet" i], input[placeholder*="weight" i], input[placeholder*="托盘" i], input[placeholder*="重量" i]', ) .first(); if (await cargoInput.isVisible().catch(() => false)) { return true; } } } return false; } /** 仅当 overlay 货物弹层仍打开时 Escape 收起(内联字段勿 Escape,会重置地址) */ export async function dismissCargoPopoverOnly( page: Page, widgetSelector: string, ): Promise { void widgetSelector; if (!(await isCargoOverlayPopoverOpen(page))) { return; } console.log("[rpa] cargo: 收起货物弹层(Escape,不点击向导步骤)"); await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(200); } /** 尝试提交内联货物步(不点击地址 chip / 步骤标签,避免重置向导) */ export async function attemptCargoCommit( page: Page, widgetSelector: string, blindFlow = false, ): Promise { if (blindFlow || isRpaBlindAddressFlow() || isRpaAddressRushMode()) { await dismissCargoPopoverOnly(page, widgetSelector); return; } 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 { const state = await page.evaluate(() => { const pallet = document.querySelector( 'input[placeholder*="How many pallets" i]', ); const weight = document.querySelector( '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 { const normalized: ExpectedCargoCommit | undefined = typeof expected === "number" ? { palletCount: expected } : expected; if (normalized && (await landingPopoverMatchesExpected(page, normalized))) { console.log("[rpa] cargo-commit: ok(landing 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: ok(landing 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 { 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 { 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("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 { return isCargoCommittedState(page, widgetSelector, expectedPalletCount); } /** 关闭 cargo popover / drawer */ export async function closeCargoEditor( page: Page, widgetSelector?: string, ): Promise { 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 { await waitForCargoCommittedState(page, widgetSelector, expectedPalletCount); }