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