import type { Locator, Page } from "playwright"; import { isRpaHumanSubmitStrict, isRpaAddressRushMode } from "@/lib/rpa/env"; import { pageLocator } from "@/lib/rpa/locator"; import { RpaError } from "@/modules/rpa/errors"; import { toAxelWholeInches } from "@/modules/quote/unit-converter"; import { expandCargoEditor, waitForCargoEditorOpen, waitForCargoUiPresent, } from "@/workers/rpa/cargo-editor"; import { attemptCargoCommit, probeCargoCommitState, } from "@/workers/rpa/cargo-lock"; import { assertWidgetCargoSummary, clickAddFreightItem, removeAllFreightRows, widgetHasFreightSummary, } from "@/workers/rpa/cargo-reset"; import { buildDimDisplayForUnit, inferDimUnitFromFieldHint, } from "@/workers/rpa/cargo-dimensions"; import { buildWeightDisplayForUnit, inferWeightUnitFromFieldHint, type MotherShipWeightDisplay, } from "@/workers/rpa/cargo-weight"; import type { ExpectedCargoCommit } from "@/workers/rpa/cargo-lock"; const POLL_MS = 200; const FIELD_WAIT_MS = 20_000; export type CargoDrawerFieldType = | "pallet" | "weight" | "length" | "width" | "height"; /** placeholder / aria-label 匹配(中英多变体) */ const FIELD_HINT_PATTERNS: Record = { pallet: /pallet|托盘|how many|件数|数量/i, weight: /weight|重量|lbs?|磅|kg|公斤|千克/i, length: /length|长度|dimension/i, width: /width|宽度/i, height: /height|高度|高\s*$/i, }; export type FillCargoInDrawerInput = { palletCount: number; weightLbPerPallet: number; dimsIn?: { l: number; w: number; h: number }; blindFlow?: boolean; }; export type FillCargoInDrawerResult = { weightDisplay: MotherShipWeightDisplay; expected: ExpectedCargoCommit; /** landing Freight details 弹层已视觉填完 */ landingPopoverFilled?: boolean; }; function normalizeAxelDimsIn( dimsIn?: { l: number; w: number; h: number }, ): { l: number; w: number; h: number } { return dimsIn ? { l: toAxelWholeInches(dimsIn.l), w: toAxelWholeInches(dimsIn.w), h: toAxelWholeInches(dimsIn.h), } : { l: 48, w: 40, h: 48 }; } export function classifyCargoFieldHint(hint: string): CargoDrawerFieldType | null { const text = hint.trim(); if (!text) { return null; } if (FIELD_HINT_PATTERNS.pallet.test(text)) { return "pallet"; } if (FIELD_HINT_PATTERNS.weight.test(text)) { return "weight"; } if (FIELD_HINT_PATTERNS.length.test(text)) { return "length"; } if (FIELD_HINT_PATTERNS.width.test(text)) { return "width"; } if (FIELD_HINT_PATTERNS.height.test(text)) { return "height"; } return null; } async function fillDimensionField( page: Page, field: Locator, inches: number, label: string, ): Promise { const hint = await readFieldHint(field); const unit = inferDimUnitFromFieldHint(hint); const displayValue = buildDimDisplayForUnit(inches, unit); console.log( `[rpa] fillCargoInDrawer: ${label} hint="${hint}" unit=${unit} fill=${displayValue} (internalIn=${inches})`, ); await fillFieldWithTab(page, field, displayValue, label); } function valuesMatch(expected: string, actual: string): boolean { if (expected === actual) { return true; } const a = Number(expected); const b = Number(actual); return Number.isFinite(a) && Number.isFinite(b) && a === b; } async function cargoFillScopes( page: Page, widgetSelector: string, ): Promise { const scopes: Locator[] = [pageLocator(page, widgetSelector)]; const dialog = page .locator('[role="dialog"], [aria-modal="true"]') .filter({ has: page.locator("input") }) .last(); if (await dialog.isVisible().catch(() => false)) { scopes.push(dialog); } scopes.push(page.locator("body")); return scopes; } /** 在 drawer / widget 内按 hint 动态定位输入框 */ export async function findCargoFieldLocator( page: Page, widgetSelector: string, fieldType: CargoDrawerFieldType, ): Promise { const pattern = FIELD_HINT_PATTERNS[fieldType]; for (const scope of await cargoFillScopes(page, widgetSelector)) { const inputs = scope.locator("input"); const count = await inputs.count().catch(() => 0); for (let i = 0; i < count; i += 1) { const loc = inputs.nth(i); const visible = await loc.isVisible().catch(() => false); const editable = visible && (await loc.isEditable().catch(() => false)); if (!editable) { continue; } const placeholder = (await loc.getAttribute("placeholder")) ?? ""; const ariaLabel = (await loc.getAttribute("aria-label")) ?? ""; const hint = `${placeholder} ${ariaLabel}`; if (pattern.test(hint)) { return loc; } } } return null; } async function waitForCargoFieldLocator( page: Page, widgetSelector: string, fieldType: CargoDrawerFieldType, waitMs = FIELD_WAIT_MS, ): Promise { const deadline = Date.now() + waitMs; while (Date.now() < deadline) { const loc = await findCargoFieldLocator(page, widgetSelector, fieldType); if (loc) { return loc; } await page.waitForTimeout(POLL_MS); } return null; } async function readFieldHint(loc: Locator): Promise { const placeholder = (await loc.getAttribute("placeholder")) ?? ""; const ariaLabel = (await loc.getAttribute("aria-label")) ?? ""; return `${placeholder} ${ariaLabel}`.trim(); } /** 填值 + Tab 提交,校验 input value */ async function fillFieldWithTab( page: Page, loc: Locator, value: string, label: string, ): Promise { await loc.scrollIntoViewIfNeeded().catch(() => undefined); await loc.click({ timeout: 5_000 }).catch(() => undefined); await loc.click({ clickCount: 3, timeout: 3_000 }).catch(() => undefined); await loc.press("Control+A").catch(() => undefined); await loc.press("Backspace").catch(() => undefined); await loc.fill(value, { timeout: 8_000 }); let actual = await loc.inputValue().catch(() => ""); if (!valuesMatch(value, actual)) { await loc.pressSequentially(value, { delay: 25 }); actual = await loc.inputValue().catch(() => ""); } await loc.evaluate((el) => { if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } }); await loc.press("Tab").catch(() => page.keyboard.press("Tab")); await page.waitForTimeout(POLL_MS); actual = await loc.inputValue().catch(() => ""); const ok = valuesMatch(value, actual); console.log( `[rpa] fillCargoInDrawer: ${label} expected=${value} actual=${actual} ok=${ok}`, ); return ok; } async function logDiscoveredFields( page: Page, widgetSelector: string, ): Promise { const fields = await page.evaluate((widgetSel) => { const roots: Element[] = []; const widget = document.querySelector(widgetSel); if (widget) { roots.push(widget); } document .querySelectorAll('[role="dialog"], [aria-modal="true"]') .forEach((el) => { if ((el as HTMLElement).offsetParent !== null) { roots.push(el); } }); const rows: Array> = []; for (const root of roots) { root.querySelectorAll("input").forEach((inp) => { rows.push({ placeholder: inp.placeholder ?? "", ariaLabel: inp.getAttribute("aria-label") ?? "", value: inp.value, visible: inp.offsetParent !== null, readOnly: inp.readOnly, }); }); } return rows; }, widgetSelector); console.log( `[rpa] fillCargoInDrawer: 当前可见 input 数=${fields.filter((f) => f.visible).length}`, JSON.stringify(fields.filter((f) => f.visible)), ); } /** MotherShip 首页横向 widget:点 Freight details → 弹出 Per pallet 表单(对齐 5519e0ea 真人录证) */ async function fillLandingFreightPopover( page: Page, widgetSelector: string, input: FillCargoInDrawerInput, ): Promise { const widget = pageLocator(page, widgetSelector); const hasFreightChip = await widget .getByText("Freight details", { exact: true }) .isVisible() .catch(() => false); if (!hasFreightChip) { return null; } console.log("[rpa] fillCargo: landing Freight details 弹层"); await waitForCargoUiPresent(page, widgetSelector); await expandCargoEditor(page, widgetSelector); await waitForCargoEditorOpen(page, widgetSelector); await logDiscoveredFields(page, widgetSelector); const dims = normalizeAxelDimsIn(input.dimsIn); const palletField = await waitForCargoFieldLocator( page, widgetSelector, "pallet", 6_000, ); if (palletField) { await fillFieldWithTab( page, palletField, String(input.palletCount), "landing-pallet", ); } const lengthField = await waitForCargoFieldLocator( page, widgetSelector, "length", 3_000, ); if (lengthField) { await fillDimensionField(page, lengthField, dims.l, "landing-length"); } const widthField = await waitForCargoFieldLocator( page, widgetSelector, "width", 3_000, ); if (widthField) { await fillDimensionField(page, widthField, dims.w, "landing-width"); } const heightField = await waitForCargoFieldLocator( page, widgetSelector, "height", 3_000, ); if (heightField) { await fillDimensionField(page, heightField, dims.h, "landing-height"); } const weightField = await waitForCargoFieldLocator( page, widgetSelector, "weight", 6_000, ); if (!weightField) { await logDiscoveredFields(page, widgetSelector); throw new RpaError( "STRUCT_CHANGE", "Freight details 已展开,但未找到重量输入框", { retryable: true }, ); } const weightHint = await readFieldHint(weightField); const weightDisplay = buildWeightDisplayForUnit( input.weightLbPerPallet, inferWeightUnitFromFieldHint(weightHint), ); await fillFieldWithTab( page, weightField, weightDisplay.displayValue, "landing-weight", ); const expected: ExpectedCargoCommit = { palletCount: input.palletCount, weight: { ...weightDisplay, palletCount: input.palletCount, }, }; console.log( `[rpa] fillCargo: landing 弹层已填 pallet=${input.palletCount} weight=${weightDisplay.displayValue}`, ); if (isRpaHumanSubmitStrict()) { await page.waitForTimeout(300); } return { weightDisplay, expected }; } /** * 货物 drawer 完整填写:Add Item → 动态字段定位 → 填托数/重量/尺寸 → 摘要断言 * 不使用 RPA_SELECTOR_WEIGHT/LENGTH/WIDTH/HEIGHT 静态选择器。 */ export async function fillCargoInDrawer( page: Page, widgetSelector: string, input: FillCargoInDrawerInput, ): Promise { console.log( `[rpa] fillCargoInDrawer: 开始 pallet=${input.palletCount} weightLb=${input.weightLbPerPallet}`, ); const landing = await fillLandingFreightPopover(page, widgetSelector, input); if (landing) { if (isRpaAddressRushMode() && !isRpaHumanSubmitStrict()) { await attemptCargoCommit(page, widgetSelector, input.blindFlow === true); } console.log("[rpa] fillCargo: landing 弹层已填并 commit"); return { ...landing, landingPopoverFilled: true }; } const probeBefore = await probeCargoCommitState(page, widgetSelector); if (widgetHasFreightSummary(probeBefore.widgetTextPreview)) { await removeAllFreightRows(page, widgetSelector); } await clickAddFreightItem(page, widgetSelector); await waitForCargoUiPresent(page, widgetSelector); await waitForCargoEditorOpen(page, widgetSelector); await logDiscoveredFields(page, widgetSelector); const dims = normalizeAxelDimsIn(input.dimsIn); const palletLoc = await waitForCargoFieldLocator( page, widgetSelector, "pallet", 12_000, ); if (!palletLoc) { console.warn( "[rpa] fillCargoInDrawer: 未找到托数字段(可能页面无托数输入),继续", ); } else { const palletOk = await fillFieldWithTab( page, palletLoc, String(input.palletCount), "pallet", ); if (!palletOk) { console.warn("[rpa] fillCargoInDrawer: 托数填写未确认生效,重试一次"); await fillFieldWithTab( page, palletLoc, String(input.palletCount), "pallet-retry", ); } await page.waitForTimeout(POLL_MS); await logDiscoveredFields(page, widgetSelector); } const weightLoc = await waitForCargoFieldLocator( page, widgetSelector, "weight", ); if (!weightLoc) { await logDiscoveredFields(page, widgetSelector); throw new RpaError( "STRUCT_CHANGE", "货物 drawer 内未找到任何重量输入框(已扫描 placeholder/aria-label)", { retryable: true }, ); } const weightHint = await readFieldHint(weightLoc); const weightDisplay = buildWeightDisplayForUnit( input.weightLbPerPallet, inferWeightUnitFromFieldHint(weightHint), ); console.log( `[rpa] fillCargoInDrawer: 重量字段 hint="${weightHint}" unit=${weightDisplay.unit} fill=${weightDisplay.displayValue}`, ); const weightOk = await fillFieldWithTab( page, weightLoc, weightDisplay.displayValue, "weight", ); if (!weightOk) { await fillFieldWithTab( page, weightLoc, weightDisplay.displayValue, "weight-retry", ); } if (input.dimsIn) { const dimFields: Array<[CargoDrawerFieldType, number]> = [ ["length", dims.l], ["width", dims.w], ["height", dims.h], ]; for (const [fieldType, dimValue] of dimFields) { const dimLoc = await waitForCargoFieldLocator( page, widgetSelector, fieldType, 8_000, ); if (!dimLoc) { console.warn( `[rpa] fillCargoInDrawer: 未找到 ${fieldType} 字段,跳过(页面可能不需要尺寸)`, ); continue; } await fillDimensionField(page, dimLoc, dimValue, fieldType); } } const expected: ExpectedCargoCommit = { palletCount: input.palletCount, weight: { ...weightDisplay, palletCount: input.palletCount, }, }; await attemptCargoCommit(page, widgetSelector, input.blindFlow === true); await assertWidgetCargoSummary(page, widgetSelector, expected); console.log("[rpa] fillCargoInDrawer: 摘要断言通过"); if (process.env.RPA_CARGO_DRAWER_PAUSE === "true") { console.log( "[rpa] fillCargoInDrawer: RPA_CARGO_DRAWER_PAUSE=true → page.pause()", ); await page.pause(); } return { weightDisplay, expected }; }