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.
chajia/workers/rpa/fill-cargo-in-drawer.ts

517 lines
14 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 { 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<CargoDrawerFieldType, RegExp> = {
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<void> {
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<Locator[]> {
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<Locator | null> {
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<Locator | null> {
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<string> {
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<boolean> {
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<void> {
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<Record<string, string | boolean>> = [];
for (const root of roots) {
root.querySelectorAll<HTMLInputElement>("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<FillCargoInDrawerResult | null> {
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<FillCargoInDrawerResult> {
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 };
}