|
|
/**
|
|
|
* Flock 登录后查价表单(Let's build a quote / Quick)
|
|
|
* 与匿名 marketing lead 表单字段不同,需单独填写必填下拉项
|
|
|
*/
|
|
|
import type { Page } from "playwright";
|
|
|
import { getFlockFieldPauseMs } from "@/lib/flock/env";
|
|
|
import { gotoWithResilience } from "@/lib/rpa/page-goto";
|
|
|
import {
|
|
|
flockFreightClassRpaLabels,
|
|
|
flockLocationRpaLabels,
|
|
|
flockPackagingRpaLabels,
|
|
|
FLOCK_DEFAULT_VEHICLE_IDS,
|
|
|
FLOCK_DELIVERY_SERVICE_RPA_LABELS,
|
|
|
FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED,
|
|
|
FLOCK_PICKUP_SERVICE_RPA_LABELS,
|
|
|
FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED,
|
|
|
FLOCK_SERVICE_RPA_LABELS,
|
|
|
FLOCK_VEHICLE_RPA_LABELS,
|
|
|
} from "@/lib/flock/logged-in-rpa-options";
|
|
|
import { FLOCK_LIMITS } from "@/lib/constants/flock-limits";
|
|
|
import { FLOCK_LOCATION_ACCESSORIALS } from "@/components/flock/flock-logged-in-quote-form";
|
|
|
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
|
|
|
import {
|
|
|
flockLocatorCount,
|
|
|
flockPageClosedMessage,
|
|
|
isFlockPageClosedError,
|
|
|
} from "@/workers/rpa/flock/page-alive";
|
|
|
|
|
|
const FIELD_WAIT_MS = 20_000;
|
|
|
const DEFAULT_DESCRIPTION = "General freight";
|
|
|
|
|
|
function pauseMs(): number {
|
|
|
return getFlockFieldPauseMs();
|
|
|
}
|
|
|
|
|
|
async function pause(page: Page): Promise<void> {
|
|
|
const ms = pauseMs();
|
|
|
if (ms > 0) {
|
|
|
await page.waitForTimeout(ms);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 是否为登录后 Quick 查价页 */
|
|
|
export async function isFlockLoggedInQuoteForm(page: Page): Promise<boolean> {
|
|
|
const markers = [
|
|
|
page.getByTestId("quote-request-quick-form__submit_button"),
|
|
|
page.getByTestId("quick-form-toggle"),
|
|
|
page.getByRole("heading", { name: /Let'?s build a quote/i }),
|
|
|
page.getByText(/Shipment Pickup/i).first(),
|
|
|
page.getByRole("textbox", { name: /Quantity/i }),
|
|
|
page.getByRole("combobox", { name: /Freight Class/i }),
|
|
|
page.getByRole("combobox", { name: /Packaging Type/i }),
|
|
|
page.getByRole("button", { name: /Generate my quote/i }),
|
|
|
];
|
|
|
for (const loc of markers) {
|
|
|
if (
|
|
|
(await flockLocatorCount(loc)) > 0 &&
|
|
|
(await loc.first().isVisible().catch(() => false))
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 登录后打开 Quick 查价(录制:home → + New quote → Quick)
|
|
|
* 截图可见主按钮文案为「 New quote」;testid 可能缺失,必须按可见文案点
|
|
|
*/
|
|
|
export async function openFlockLoggedInQuoteEntry(page: Page): Promise<boolean> {
|
|
|
if (await isFlockLoggedInQuoteForm(page)) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
const homeUrl =
|
|
|
process.env.FLOCK_LOGGED_IN_HOME_URL?.trim() ||
|
|
|
"https://app.flockfreight.com/home";
|
|
|
if (!/\/home(?:\/|$|\?)/i.test(page.url())) {
|
|
|
await gotoWithResilience(page, homeUrl);
|
|
|
}
|
|
|
|
|
|
// 等 dashboard 主 CTA 出现(慢网 / hydration)
|
|
|
await page
|
|
|
.getByRole("button", { name: /New quote/i })
|
|
|
.or(page.getByTestId("header-new-quote-button"))
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 20_000 })
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
const newQuoteLocators = [
|
|
|
page.getByTestId("header-new-quote-button"),
|
|
|
page.locator('button:has-text("New quote")'),
|
|
|
page.locator('a:has-text("New quote")'),
|
|
|
page.getByRole("button", { name: /New quote/i }),
|
|
|
page.getByRole("link", { name: /New quote/i }),
|
|
|
page.getByRole("link", { name: /Request Quote/i }),
|
|
|
];
|
|
|
|
|
|
let clickedNew = false;
|
|
|
for (const loc of newQuoteLocators) {
|
|
|
const target = loc.first();
|
|
|
const n = await flockLocatorCount(target);
|
|
|
if (n === 0) continue;
|
|
|
const visible = await target.isVisible().catch(() => false);
|
|
|
console.log(`[flock-form] New quote candidate count=${n}`);
|
|
|
if (!visible) continue;
|
|
|
try {
|
|
|
await target.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await target.click({ timeout: 5_000 });
|
|
|
clickedNew = true;
|
|
|
console.log("[flock-login] 已点击 New quote");
|
|
|
break;
|
|
|
} catch (err) {
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
console.warn(`[flock-login] New quote 普通点击失败,改 force: ${msg.slice(0, 80)}`);
|
|
|
try {
|
|
|
await target.click({ force: true, timeout: 5_000 });
|
|
|
clickedNew = true;
|
|
|
console.log("[flock-login] 已 force 点击 New quote");
|
|
|
break;
|
|
|
} catch (err2) {
|
|
|
console.warn(
|
|
|
`[flock-login] force 点击仍失败: ${
|
|
|
err2 instanceof Error ? err2.message.slice(0, 80) : err2
|
|
|
}`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!clickedNew) {
|
|
|
console.warn("[flock-login] 未点到 New quote,尝试顶栏 Request Quote");
|
|
|
const req = page.getByRole("link", { name: /^Request Quote$/i }).first();
|
|
|
if (await req.isVisible().catch(() => false)) {
|
|
|
await req.click({ force: true }).catch(() => undefined);
|
|
|
clickedNew = true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(1_000);
|
|
|
|
|
|
const quickCandidates = [
|
|
|
page.getByTestId("quick-form-toggle"),
|
|
|
page.getByRole("button", { name: /^Quick$/i }),
|
|
|
page.getByRole("tab", { name: /^Quick$/i }),
|
|
|
];
|
|
|
for (const loc of quickCandidates) {
|
|
|
const target = loc.first();
|
|
|
if (await target.isVisible({ timeout: 4_000 }).catch(() => false)) {
|
|
|
await target.click({ force: true }).catch(() => undefined);
|
|
|
console.log("[flock-login] 已切 Quick");
|
|
|
await page.waitForTimeout(800);
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const deadline = Date.now() + 20_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isFlockLoggedInQuoteForm(page)) {
|
|
|
return true;
|
|
|
}
|
|
|
await page.waitForTimeout(400);
|
|
|
}
|
|
|
|
|
|
return isFlockLoggedInQuoteForm(page);
|
|
|
}
|
|
|
|
|
|
async function fillTextbox(
|
|
|
page: Page,
|
|
|
names: Array<string | RegExp>,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
for (const name of names) {
|
|
|
const loc = page.getByRole("textbox", { name }).first();
|
|
|
try {
|
|
|
await loc.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
|
|
|
await loc.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
await loc.fill(value);
|
|
|
return true;
|
|
|
} catch (error) {
|
|
|
if (isFlockPageClosedError(error)) throw error;
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function fillTextarea(
|
|
|
page: Page,
|
|
|
names: Array<string | RegExp>,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
for (const name of names) {
|
|
|
const byRole = page.getByRole("textbox", { name }).first();
|
|
|
if ((await flockLocatorCount(byRole)) > 0) {
|
|
|
try {
|
|
|
await byRole.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
|
|
|
await byRole.fill(value);
|
|
|
return true;
|
|
|
} catch {
|
|
|
/* try label */
|
|
|
}
|
|
|
}
|
|
|
const byLabel = page.getByLabel(name).first();
|
|
|
if ((await flockLocatorCount(byLabel)) > 0) {
|
|
|
try {
|
|
|
await byLabel.fill(value);
|
|
|
return true;
|
|
|
} catch {
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 打开 combobox / 下拉,按候选精确或包含匹配;禁止误点第一项 */
|
|
|
export async function selectFlockComboboxOption(
|
|
|
page: Page,
|
|
|
fieldName: RegExp,
|
|
|
optionCandidates: string[],
|
|
|
): Promise<boolean> {
|
|
|
const combo = page.getByRole("combobox", { name: fieldName }).first();
|
|
|
const button = page.getByRole("button", { name: fieldName }).first();
|
|
|
const trigger =
|
|
|
(await flockLocatorCount(combo)) > 0
|
|
|
? combo
|
|
|
: (await flockLocatorCount(button)) > 0
|
|
|
? button
|
|
|
: null;
|
|
|
if (!trigger) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
await trigger.click({ force: true });
|
|
|
await page.waitForTimeout(400);
|
|
|
|
|
|
// 等下拉 option 出现(MUI listbox 可能晚于 click)
|
|
|
await page
|
|
|
.getByRole("option")
|
|
|
.first()
|
|
|
.waitFor({ state: "attached", timeout: 4_000 })
|
|
|
.catch(() => undefined);
|
|
|
await page.waitForTimeout(200);
|
|
|
|
|
|
const normalize = (s: string) =>
|
|
|
s
|
|
|
.toLowerCase()
|
|
|
.replace(/[–—−]/g, "-")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.replace(/[^\w\s/-]/g, "")
|
|
|
.trim();
|
|
|
|
|
|
async function collectOptionTexts(): Promise<
|
|
|
Array<{ index: number; text: string }>
|
|
|
> {
|
|
|
const seen = new Set<string>();
|
|
|
const rows: Array<{ index: number; text: string }> = [];
|
|
|
// 虚拟列表:方向键滚动加载
|
|
|
for (let i = 0; i < 40; i++) {
|
|
|
const options = page.getByRole("option");
|
|
|
const n = await flockLocatorCount(options);
|
|
|
for (let j = 0; j < n; j++) {
|
|
|
const opt = options.nth(j);
|
|
|
const raw = ((await opt.innerText().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (!raw || seen.has(raw)) continue;
|
|
|
seen.add(raw);
|
|
|
rows.push({ index: j, text: raw });
|
|
|
}
|
|
|
await page.keyboard.press("ArrowDown").catch(() => undefined);
|
|
|
await page.waitForTimeout(40);
|
|
|
}
|
|
|
return rows;
|
|
|
}
|
|
|
|
|
|
async function clickMatchingOption(candidate: string): Promise<boolean> {
|
|
|
const exact = page
|
|
|
.getByRole("option", {
|
|
|
name: new RegExp(`^${escapeRe(candidate)}$`, "i"),
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(exact)) > 0) {
|
|
|
await exact.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await exact.click({ force: true });
|
|
|
return true;
|
|
|
}
|
|
|
const loose = page.getByRole("option", { name: candidate }).first();
|
|
|
if ((await flockLocatorCount(loose)) > 0) {
|
|
|
await loose.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await loose.click({ force: true });
|
|
|
return true;
|
|
|
}
|
|
|
// 含匹配(Limited Access – Airport 等)
|
|
|
const contains = page
|
|
|
.getByRole("option", {
|
|
|
name: new RegExp(escapeRe(candidate), "i"),
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(contains)) > 0) {
|
|
|
await contains.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await contains.click({ force: true });
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
// 1) 直接按候选点
|
|
|
for (const candidate of optionCandidates) {
|
|
|
if (await clickMatchingOption(candidate)) return true;
|
|
|
}
|
|
|
|
|
|
// 2) 输入过滤(MUI Autocomplete)再点
|
|
|
for (const candidate of optionCandidates) {
|
|
|
const filterKey = candidate.replace(/^Limited Access\s*[-–]\s*/i, "").trim();
|
|
|
if (filterKey.length < 2) continue;
|
|
|
await trigger.click({ force: true }).catch(() => undefined);
|
|
|
await page.keyboard.press("Control+A").catch(() => undefined);
|
|
|
await page.keyboard.type(filterKey.slice(0, 24), { delay: 20 }).catch(() => undefined);
|
|
|
await page.waitForTimeout(350);
|
|
|
if (await clickMatchingOption(candidate)) return true;
|
|
|
if (await clickMatchingOption(filterKey)) return true;
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
await page.waitForTimeout(200);
|
|
|
await trigger.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(300);
|
|
|
}
|
|
|
|
|
|
// 3) 收集全部 option 文本后模糊匹配
|
|
|
await trigger.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(300);
|
|
|
const rows = await collectOptionTexts();
|
|
|
const wantNorms = optionCandidates.map(normalize);
|
|
|
const hit = rows.find((row) => {
|
|
|
const n = normalize(row.text);
|
|
|
return wantNorms.some(
|
|
|
(w) => n === w || n.includes(w) || w.includes(n) || n.endsWith(w),
|
|
|
);
|
|
|
});
|
|
|
if (hit) {
|
|
|
const opt = page.getByRole("option", { name: hit.text }).first();
|
|
|
if ((await flockLocatorCount(opt)) > 0) {
|
|
|
await opt.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await opt.click({ force: true });
|
|
|
return true;
|
|
|
}
|
|
|
// 虚拟列表:再用键盘定位
|
|
|
await page.keyboard.press("Home").catch(() => undefined);
|
|
|
for (let i = 0; i < rows.length + 5; i++) {
|
|
|
const focused = page.locator('[role="option"][aria-selected="true"], [role="option"].Mui-focused').first();
|
|
|
const t = ((await focused.innerText().catch(() => "")) || "").trim();
|
|
|
if (t && normalize(t) === normalize(hit.text)) {
|
|
|
await page.keyboard.press("Enter");
|
|
|
return true;
|
|
|
}
|
|
|
await page.keyboard.press("ArrowDown").catch(() => undefined);
|
|
|
await page.waitForTimeout(40);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.warn(
|
|
|
`[flock-form] combobox 未匹配: field=${fieldName} candidates=${optionCandidates.join("|")} visible=[${rows.map((r) => r.text).slice(0, 20).join(" | ")}]`,
|
|
|
);
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
function escapeRe(value: string): string {
|
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
}
|
|
|
|
|
|
/** 登录后日期:Shipment Pickup + Choose date */
|
|
|
export async function fillLoggedInPickupDate(
|
|
|
page: Page,
|
|
|
pickupDateDisplay: string,
|
|
|
): Promise<boolean> {
|
|
|
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(pickupDateDisplay.trim());
|
|
|
const dayRaw = m?.[2];
|
|
|
const dayNum = dayRaw ? String(Number(dayRaw)) : null;
|
|
|
|
|
|
const openers = [
|
|
|
page.getByRole("button", { name: /Choose date|选择日期/i }).first(),
|
|
|
page.getByLabel(/Shipment Pickup/i).first(),
|
|
|
page.getByRole("textbox", { name: /Shipment Pickup|Pickup Date/i }).first(),
|
|
|
];
|
|
|
|
|
|
for (const opener of openers) {
|
|
|
if ((await flockLocatorCount(opener)) === 0) continue;
|
|
|
await opener.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(400);
|
|
|
if (!dayNum || !dayRaw) break;
|
|
|
for (const dayLabel of [dayNum, dayRaw]) {
|
|
|
const cell = page.getByRole("gridcell", { name: dayLabel }).first();
|
|
|
if ((await flockLocatorCount(cell)) > 0) {
|
|
|
await cell.click();
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return fillTextbox(
|
|
|
page,
|
|
|
[/Shipment Pickup/i, /Pickup Date/i],
|
|
|
pickupDateDisplay,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 确保 Quick 模式(非 Guided 分步) */
|
|
|
async function ensureQuickMode(page: Page): Promise<void> {
|
|
|
const quick = page.getByRole("button", { name: /^Quick$/i }).first();
|
|
|
if ((await flockLocatorCount(quick)) === 0) return;
|
|
|
const pressed = await quick.getAttribute("aria-pressed").catch(() => null);
|
|
|
const selected = await quick.getAttribute("aria-selected").catch(() => null);
|
|
|
if (pressed === "true" || selected === "true") return;
|
|
|
await quick.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(500);
|
|
|
}
|
|
|
|
|
|
export type LoggedInFormInput = Pick<
|
|
|
FlockQuoteInput,
|
|
|
| "pickupDate"
|
|
|
| "pickupZip"
|
|
|
| "deliveryZip"
|
|
|
| "palletCount"
|
|
|
| "totalWeightLb"
|
|
|
| "lengthIn"
|
|
|
| "widthIn"
|
|
|
| "heightIn"
|
|
|
| "pickupLocationType"
|
|
|
| "deliveryLocationType"
|
|
|
| "packagingType"
|
|
|
| "freightClass"
|
|
|
| "description"
|
|
|
| "stackable"
|
|
|
| "turnable"
|
|
|
| "additionalServices"
|
|
|
| "vehicleTypes"
|
|
|
| "callBeforePickup"
|
|
|
| "callBeforeDelivery"
|
|
|
| "pickupLiftgate"
|
|
|
| "pickupInside"
|
|
|
| "pickupPalletJack"
|
|
|
| "deliveryLiftgate"
|
|
|
| "deliveryInside"
|
|
|
| "deliveryPalletJack"
|
|
|
| "pickupService"
|
|
|
| "deliveryService"
|
|
|
| "pickupWindowStartTime"
|
|
|
| "pickupWindowEndTime"
|
|
|
| "pickupAppointmentTime"
|
|
|
| "deliveryWindowStartTime"
|
|
|
| "deliveryWindowEndTime"
|
|
|
| "deliveryWindowDate"
|
|
|
| "deliveryAppointmentTime"
|
|
|
| "deliveryAppointmentDate"
|
|
|
| "deliveryMustArriveByDate"
|
|
|
| "callForDeliveryAppointment"
|
|
|
>;
|
|
|
|
|
|
async function setCheckbox(
|
|
|
page: Page,
|
|
|
name: RegExp,
|
|
|
checked: boolean,
|
|
|
index = 0,
|
|
|
): Promise<boolean> {
|
|
|
const box = page.getByRole("checkbox", { name }).nth(index);
|
|
|
if ((await flockLocatorCount(box)) === 0) return false;
|
|
|
const isChecked = await box.isChecked().catch(() => false);
|
|
|
if (isChecked === checked) return true;
|
|
|
await box.click({ force: true });
|
|
|
await page.waitForTimeout(200);
|
|
|
const after = await box.isChecked().catch(() => !checked);
|
|
|
return after === checked;
|
|
|
}
|
|
|
|
|
|
/** 按官网可见文案勾选地点附属项(含 wait + 重试) */
|
|
|
async function ensureLabeledCheckbox(
|
|
|
page: Page,
|
|
|
labelCandidates: string[],
|
|
|
checked: boolean,
|
|
|
): Promise<boolean> {
|
|
|
for (const text of labelCandidates) {
|
|
|
const byRole = page
|
|
|
.getByRole("checkbox", { name: new RegExp(escapeRe(text), "i") })
|
|
|
.first();
|
|
|
const input = page
|
|
|
.locator("label")
|
|
|
.filter({ hasText: new RegExp(escapeRe(text), "i") })
|
|
|
.locator('input[type="checkbox"]')
|
|
|
.first();
|
|
|
|
|
|
for (const loc of [byRole, input]) {
|
|
|
try {
|
|
|
await loc.waitFor({ state: "visible", timeout: 3_000 });
|
|
|
} catch {
|
|
|
continue;
|
|
|
}
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
const on = await loc.isChecked().catch(() => false);
|
|
|
if (on === checked) return true;
|
|
|
try {
|
|
|
if (checked) await loc.check({ force: true, timeout: 2_000 });
|
|
|
else await loc.uncheck({ force: true, timeout: 2_000 });
|
|
|
} catch {
|
|
|
await loc.click({ force: true }).catch(() => undefined);
|
|
|
}
|
|
|
await page.waitForTimeout(250);
|
|
|
const after = await loc.isChecked().catch(() => on);
|
|
|
if (after === checked) return true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
function locationAccessorialFlags(typeId?: string): {
|
|
|
liftgate: boolean;
|
|
|
inside: boolean;
|
|
|
palletJack: boolean;
|
|
|
} | null {
|
|
|
if (!typeId) return null;
|
|
|
return (
|
|
|
FLOCK_LOCATION_ACCESSORIALS[
|
|
|
typeId as keyof typeof FLOCK_LOCATION_ACCESSORIALS
|
|
|
] ?? null
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 地点类型附属勾选项(对齐官网文案:Liftgate Required for Pickup 等) */
|
|
|
async function applyLoggedInLocationAccessorials(
|
|
|
page: Page,
|
|
|
input: LoggedInFormInput,
|
|
|
): Promise<void> {
|
|
|
// 等 Type 切换后附属行渲染
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
|
const PALLET_LABELS = [
|
|
|
"Location has Pallet Jack and Forklift",
|
|
|
"Location equipped with pallet jack and forklift",
|
|
|
];
|
|
|
|
|
|
const pickupFlags = locationAccessorialFlags(input.pickupLocationType);
|
|
|
const deliveryFlags = locationAccessorialFlags(input.deliveryLocationType);
|
|
|
|
|
|
// 仅当类型矩阵声明存在该附属项时才操作,避免住宅无 Pallet 时误动派送侧同名框
|
|
|
if (pickupFlags?.liftgate && typeof input.pickupLiftgate === "boolean") {
|
|
|
await ensureLabeledCheckbox(
|
|
|
page,
|
|
|
["Liftgate Required for Pickup"],
|
|
|
input.pickupLiftgate,
|
|
|
);
|
|
|
}
|
|
|
if (pickupFlags?.inside && typeof input.pickupInside === "boolean") {
|
|
|
await ensureLabeledCheckbox(page, ["Inside Pickup"], input.pickupInside);
|
|
|
}
|
|
|
if (pickupFlags?.palletJack && typeof input.pickupPalletJack === "boolean") {
|
|
|
const ok = await ensureLabeledCheckbox(
|
|
|
page,
|
|
|
PALLET_LABELS,
|
|
|
input.pickupPalletJack,
|
|
|
);
|
|
|
if (!ok && input.pickupPalletJack) {
|
|
|
console.warn("[flock-form] Pickup Pallet Jack 勾选失败");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (deliveryFlags?.liftgate && typeof input.deliveryLiftgate === "boolean") {
|
|
|
await ensureLabeledCheckbox(
|
|
|
page,
|
|
|
["Liftgate Required for Delivery"],
|
|
|
input.deliveryLiftgate,
|
|
|
);
|
|
|
}
|
|
|
if (deliveryFlags?.inside && typeof input.deliveryInside === "boolean") {
|
|
|
await ensureLabeledCheckbox(
|
|
|
page,
|
|
|
["Inside Delivery"],
|
|
|
input.deliveryInside,
|
|
|
);
|
|
|
}
|
|
|
if (
|
|
|
deliveryFlags?.palletJack &&
|
|
|
typeof input.deliveryPalletJack === "boolean"
|
|
|
) {
|
|
|
const pallets = page.getByRole("checkbox", {
|
|
|
name: /Location has Pallet Jack and Forklift/i,
|
|
|
});
|
|
|
const n = await flockLocatorCount(pallets);
|
|
|
const box =
|
|
|
n > 1 && pickupFlags?.palletJack ? pallets.nth(1) : pallets.first();
|
|
|
if (n > 0) {
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
const on = await box.isChecked().catch(() => false);
|
|
|
if (on === input.deliveryPalletJack) break;
|
|
|
try {
|
|
|
if (input.deliveryPalletJack) {
|
|
|
await box.check({ force: true });
|
|
|
} else {
|
|
|
await box.uncheck({ force: true });
|
|
|
}
|
|
|
} catch {
|
|
|
await box.click({ force: true }).catch(() => undefined);
|
|
|
}
|
|
|
await page.waitForTimeout(250);
|
|
|
}
|
|
|
} else {
|
|
|
await ensureLabeledCheckbox(
|
|
|
page,
|
|
|
PALLET_LABELS,
|
|
|
input.deliveryPalletJack,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Options: Stackable / Turnable
|
|
|
* 禁止「勾 checkbox 再点文案」——会二次切换把已选取消
|
|
|
*/
|
|
|
async function ensureOptionToggle(
|
|
|
page: Page,
|
|
|
label: "Stackable" | "Turnable",
|
|
|
wantOn: boolean,
|
|
|
): Promise<void> {
|
|
|
const byRole = page
|
|
|
.getByRole("checkbox", { name: new RegExp(`^${label}$`, "i") })
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(byRole)) > 0) {
|
|
|
const on = await byRole.isChecked().catch(() => false);
|
|
|
if (on === wantOn) {
|
|
|
console.log(`[flock-form] Options ${label} 已是目标态,跳过`);
|
|
|
return;
|
|
|
}
|
|
|
await byRole.click({ force: true });
|
|
|
await page.waitForTimeout(250);
|
|
|
const after = await byRole.isChecked().catch(() => on);
|
|
|
console.log(`[flock-form] Options ${label} -> ${after ? "on" : "off"}`);
|
|
|
if (after !== wantOn) {
|
|
|
await byRole.click({ force: true });
|
|
|
await page.waitForTimeout(200);
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const labelLoc = page.getByText(new RegExp(`^${label}$`, "i")).first();
|
|
|
if (!(await labelLoc.isVisible().catch(() => false))) return;
|
|
|
const inputNear = page
|
|
|
.locator(
|
|
|
`label:has-text("${label}") input[type="checkbox"], input[type="checkbox"][name*="${label}" i]`,
|
|
|
)
|
|
|
.first();
|
|
|
const before =
|
|
|
(await flockLocatorCount(inputNear)) > 0
|
|
|
? await inputNear.isChecked().catch(() => false)
|
|
|
: false;
|
|
|
if (before === wantOn) {
|
|
|
console.log(`[flock-form] Options ${label}(label) 已是目标态,跳过`);
|
|
|
return;
|
|
|
}
|
|
|
await labelLoc.click({ force: true });
|
|
|
await page.waitForTimeout(250);
|
|
|
console.log(`[flock-form] Options ${label}(label) toggled`);
|
|
|
}
|
|
|
|
|
|
/** MUI chip:以 CheckCircle / aria-pressed 为准(勿用 outlined/svg 误判) */
|
|
|
async function isChipSelected(
|
|
|
loc: import("playwright").Locator,
|
|
|
): Promise<boolean> {
|
|
|
return loc.evaluate((el) => {
|
|
|
const aria =
|
|
|
el.getAttribute("aria-pressed") ?? el.getAttribute("aria-checked");
|
|
|
if (aria === "true") return true;
|
|
|
if (aria === "false") return false;
|
|
|
if (
|
|
|
el.querySelector(
|
|
|
'[data-testid="CheckCircleIcon"], [data-testid="CheckIcon"], [data-testid="DoneIcon"]',
|
|
|
)
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
for (const svg of Array.from(el.querySelectorAll("svg"))) {
|
|
|
const tip =
|
|
|
(svg.getAttribute("data-testid") || "") +
|
|
|
" " +
|
|
|
(svg.getAttribute("class") || "");
|
|
|
if (/CheckCircle|CheckIcon|DoneIcon/i.test(tip)) return true;
|
|
|
}
|
|
|
return el.classList.contains("Mui-selected");
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function ensureChip(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
wantOn: boolean,
|
|
|
): Promise<"ok" | "skipped" | "toggled"> {
|
|
|
const btn = page.getByRole("button", { name: label }).first();
|
|
|
if (!(await btn.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
|
|
console.warn(`[flock-form] 芯片不可见: ${label}`);
|
|
|
return "skipped";
|
|
|
}
|
|
|
const on = await isChipSelected(btn);
|
|
|
if (on === wantOn) return "ok";
|
|
|
await btn.click({ force: true });
|
|
|
await page.waitForTimeout(350);
|
|
|
const after = await isChipSelected(btn);
|
|
|
console.log(
|
|
|
`[flock-form] 芯片 ${label}: ${on ? "开" : "关"}→${wantOn ? "开" : "关"} 实测=${after ? "开" : "关"}`,
|
|
|
);
|
|
|
return "toggled";
|
|
|
}
|
|
|
|
|
|
function isDefaultVehicleSet(ids: string[]): boolean {
|
|
|
const want = new Set<string>(FLOCK_DEFAULT_VEHICLE_IDS);
|
|
|
const got = new Set(ids.map((s) => s.trim()).filter(Boolean));
|
|
|
if (want.size !== got.size) return false;
|
|
|
for (const id of want) {
|
|
|
if (!got.has(id)) return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
function toUsDateDisplay(raw: string): string {
|
|
|
const t = raw.trim();
|
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);
|
|
|
if (m) return `${m[2]}/${m[3]}/${m[1]}`;
|
|
|
return t;
|
|
|
}
|
|
|
|
|
|
function schedulingWeightUnlocked(totalWeightLb: number): boolean {
|
|
|
return (
|
|
|
Number.isFinite(totalWeightLb) &&
|
|
|
totalWeightLb > FLOCK_LIMITS.schedulingWeightMinLb
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function selectSchedulingRadio(
|
|
|
page: Page,
|
|
|
candidates: readonly string[],
|
|
|
): Promise<boolean> {
|
|
|
for (const label of candidates) {
|
|
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
const radio = page
|
|
|
.getByRole("radio", { name: new RegExp(escaped, "i") })
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(radio)) === 0) continue;
|
|
|
if (await radio.isDisabled().catch(() => true)) {
|
|
|
console.log(`[flock-form] 调度 radio 禁用跳过: ${label}`);
|
|
|
continue;
|
|
|
}
|
|
|
const on = await radio.isChecked().catch(() => false);
|
|
|
if (on) return true;
|
|
|
try {
|
|
|
await radio.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
// 官网自定义 radio 偶发不可点;短超时 + force,失败再试 label
|
|
|
await radio.click({ force: true, timeout: 5_000 });
|
|
|
await page.waitForTimeout(300);
|
|
|
return true;
|
|
|
} catch {
|
|
|
const lbl = page
|
|
|
.locator("label")
|
|
|
.filter({ hasText: new RegExp(escaped, "i") })
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(lbl)) > 0) {
|
|
|
const clicked = await lbl
|
|
|
.click({ force: true, timeout: 5_000 })
|
|
|
.then(() => true)
|
|
|
.catch(() => false);
|
|
|
if (clicked) {
|
|
|
await page.waitForTimeout(300);
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
console.warn(`[flock-form] 调度 radio 点击失败,跳过: ${label}`);
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function fillSchedulingTimeField(
|
|
|
page: Page,
|
|
|
fieldName: RegExp,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
if (!value.trim()) return false;
|
|
|
if (await selectFlockComboboxOption(page, fieldName, [value, value.trim()])) {
|
|
|
return true;
|
|
|
}
|
|
|
// 部分页面 Time 为 native select / textbox
|
|
|
const combo = page.getByRole("combobox", { name: fieldName }).first();
|
|
|
if ((await flockLocatorCount(combo)) > 0) {
|
|
|
await combo.click({ force: true }).catch(() => undefined);
|
|
|
await page.waitForTimeout(200);
|
|
|
const opt = page.getByRole("option", { name: value }).first();
|
|
|
if ((await flockLocatorCount(opt)) > 0) {
|
|
|
await opt.click({ force: true });
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
const box = page.getByRole("textbox", { name: fieldName }).first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
await box.fill(value).catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function fillSchedulingDateField(
|
|
|
page: Page,
|
|
|
fieldName: RegExp,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
const display = toUsDateDisplay(value);
|
|
|
if (!display) return false;
|
|
|
const box = page.getByRole("textbox", { name: fieldName }).first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
await box.click({ force: true }).catch(() => undefined);
|
|
|
await box.fill("").catch(() => undefined);
|
|
|
await box.fill(display).catch(() => undefined);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
return selectFlockComboboxOption(page, fieldName, [display, value]);
|
|
|
}
|
|
|
|
|
|
/** 调度服务(总重 >5000 启用灰色项)+ 下级时间/日期 */
|
|
|
async function applyLoggedInScheduling(
|
|
|
page: Page,
|
|
|
input: LoggedInFormInput,
|
|
|
): Promise<void> {
|
|
|
const section = page
|
|
|
.getByRole("button", { name: /Scheduling Services/i })
|
|
|
.first();
|
|
|
if (await section.isVisible().catch(() => false)) {
|
|
|
await section.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(350);
|
|
|
}
|
|
|
|
|
|
const weightOk = schedulingWeightUnlocked(input.totalWeightLb);
|
|
|
let pickupService = (input.pickupService ?? "standard_fcfs").trim();
|
|
|
let deliveryService = (input.deliveryService ?? "standard_fcfs").trim();
|
|
|
|
|
|
if (
|
|
|
FLOCK_PICKUP_SERVICE_WEIGHT_LOCKED[pickupService] &&
|
|
|
!weightOk
|
|
|
) {
|
|
|
console.log(
|
|
|
`[flock-form] 提货调度 ${pickupService} 需总重>${FLOCK_LIMITS.schedulingWeightMinLb},回退 standard_fcfs`,
|
|
|
);
|
|
|
pickupService = "standard_fcfs";
|
|
|
}
|
|
|
if (
|
|
|
FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED[deliveryService] &&
|
|
|
!weightOk
|
|
|
) {
|
|
|
console.log(
|
|
|
`[flock-form] 派送调度 ${deliveryService} 需总重>${FLOCK_LIMITS.schedulingWeightMinLb},回退 standard_fcfs`,
|
|
|
);
|
|
|
deliveryService = "standard_fcfs";
|
|
|
}
|
|
|
|
|
|
const pickupLabels =
|
|
|
FLOCK_PICKUP_SERVICE_RPA_LABELS[pickupService] ??
|
|
|
FLOCK_PICKUP_SERVICE_RPA_LABELS.standard_fcfs;
|
|
|
if (!(await selectSchedulingRadio(page, pickupLabels!))) {
|
|
|
console.warn(`[flock-form] 未选中提货调度: ${pickupService}`);
|
|
|
} else if (pickupService === "during_window") {
|
|
|
if (input.pickupWindowStartTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/Start Time/i,
|
|
|
input.pickupWindowStartTime,
|
|
|
);
|
|
|
}
|
|
|
if (input.pickupWindowEndTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/End Time/i,
|
|
|
input.pickupWindowEndTime,
|
|
|
);
|
|
|
}
|
|
|
} else if (pickupService === "have_appointment" && input.pickupAppointmentTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/^Time\*|^Time$/i,
|
|
|
input.pickupAppointmentTime,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const deliveryLabels =
|
|
|
FLOCK_DELIVERY_SERVICE_RPA_LABELS[deliveryService] ??
|
|
|
FLOCK_DELIVERY_SERVICE_RPA_LABELS.standard_fcfs;
|
|
|
if (!(await selectSchedulingRadio(page, deliveryLabels!))) {
|
|
|
console.warn(`[flock-form] 未选中派送调度: ${deliveryService}`);
|
|
|
} else if (deliveryService === "during_window") {
|
|
|
if (input.deliveryWindowStartTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/Start Time/i,
|
|
|
input.deliveryWindowStartTime,
|
|
|
);
|
|
|
}
|
|
|
if (input.deliveryWindowEndTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/End Time/i,
|
|
|
input.deliveryWindowEndTime,
|
|
|
);
|
|
|
}
|
|
|
if (input.deliveryWindowDate) {
|
|
|
await fillSchedulingDateField(
|
|
|
page,
|
|
|
/^Date\*|^Date$/i,
|
|
|
input.deliveryWindowDate,
|
|
|
);
|
|
|
}
|
|
|
} else if (deliveryService === "have_appointment") {
|
|
|
if (input.deliveryAppointmentTime) {
|
|
|
await fillSchedulingTimeField(
|
|
|
page,
|
|
|
/^Time\*|^Time$/i,
|
|
|
input.deliveryAppointmentTime,
|
|
|
);
|
|
|
}
|
|
|
if (input.deliveryAppointmentDate) {
|
|
|
await fillSchedulingDateField(
|
|
|
page,
|
|
|
/^Date\*|^Date$/i,
|
|
|
input.deliveryAppointmentDate,
|
|
|
);
|
|
|
}
|
|
|
} else if (deliveryService === "must_arrive_by") {
|
|
|
if (input.deliveryMustArriveByDate) {
|
|
|
await fillSchedulingDateField(
|
|
|
page,
|
|
|
/^Date\*|^Date$/i,
|
|
|
input.deliveryMustArriveByDate,
|
|
|
);
|
|
|
}
|
|
|
if (typeof input.callForDeliveryAppointment === "boolean") {
|
|
|
const box = page
|
|
|
.getByRole("checkbox", {
|
|
|
name: /Call for delivery appointment/i,
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
const disabled = await box.isDisabled().catch(() => true);
|
|
|
if (!disabled) {
|
|
|
const on = await box.isChecked().catch(() => false);
|
|
|
if (on !== input.callForDeliveryAppointment) {
|
|
|
await box.click({ force: true }).catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
await pause(page);
|
|
|
}
|
|
|
|
|
|
/** 附加服务 + 车型(对齐标准:Unloading + 默认三车型) */
|
|
|
async function applyLoggedInServicesAndVehicles(
|
|
|
page: Page,
|
|
|
input: LoggedInFormInput,
|
|
|
): Promise<void> {
|
|
|
const unloadProbe = page
|
|
|
.getByRole("button", { name: "Unloading Services" })
|
|
|
.first();
|
|
|
if (!(await unloadProbe.isVisible().catch(() => false))) {
|
|
|
const section = page
|
|
|
.getByRole("button", { name: /Additional Services/i })
|
|
|
.first();
|
|
|
if (await section.isVisible().catch(() => false)) {
|
|
|
await section.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(400);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const wantServices = new Set(
|
|
|
(Array.isArray(input.additionalServices)
|
|
|
? input.additionalServices
|
|
|
: ["unloading"]
|
|
|
).map((s) => s.trim()).filter(Boolean),
|
|
|
);
|
|
|
console.log(
|
|
|
`[flock-form] 附加服务目标=[${[...wantServices].join(",")}]`,
|
|
|
);
|
|
|
for (const [id, label] of Object.entries(FLOCK_SERVICE_RPA_LABELS)) {
|
|
|
await ensureChip(page, label, wantServices.has(id));
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const wantVehicleIds = (
|
|
|
input.vehicleTypes?.length
|
|
|
? input.vehicleTypes
|
|
|
: [...FLOCK_DEFAULT_VEHICLE_IDS]
|
|
|
).map((s) => s.trim());
|
|
|
|
|
|
/**
|
|
|
* 官网默认已勾选 Box Truck / Dry Van / Refrigerated。
|
|
|
* 误判选中态再点 = 把默认关掉,只剩冷藏车还会触发温控校验。
|
|
|
* 目标即默认三选时:禁止点击车型芯片。
|
|
|
*/
|
|
|
if (isDefaultVehicleSet(wantVehicleIds)) {
|
|
|
console.log(
|
|
|
"[flock-form] 车型为目标默认三选,跳过点击(保留官网默认,防误取消)",
|
|
|
);
|
|
|
} else {
|
|
|
const wantVehicles = new Set(wantVehicleIds);
|
|
|
console.log(
|
|
|
`[flock-form] 车型自定义目标=[${[...wantVehicles].join(",")}]`,
|
|
|
);
|
|
|
for (const [id, label] of Object.entries(FLOCK_VEHICLE_RPA_LABELS)) {
|
|
|
await ensureChip(page, label, wantVehicles.has(id));
|
|
|
}
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
// 若只剩冷藏车且无温控:把默认厢式/干货加回来,消除阻断提交的红字
|
|
|
const reefer = page
|
|
|
.getByRole("button", { name: "Refrigerated Truck" })
|
|
|
.first();
|
|
|
const box = page.getByRole("button", { name: "Box Truck" }).first();
|
|
|
const dry = page.getByRole("button", { name: "Dry Van" }).first();
|
|
|
const temp = page.getByRole("button", { name: "Temperature Control" }).first();
|
|
|
if (
|
|
|
(await reefer.isVisible().catch(() => false)) &&
|
|
|
(await isChipSelected(reefer)) &&
|
|
|
!(await isChipSelected(temp).catch(() => false))
|
|
|
) {
|
|
|
if (await box.isVisible().catch(() => false)) {
|
|
|
if (!(await isChipSelected(box))) {
|
|
|
await box.click({ force: true });
|
|
|
console.log("[flock-form] 恢复 Box Truck(避免仅冷藏车阻断)");
|
|
|
}
|
|
|
}
|
|
|
if (await dry.isVisible().catch(() => false)) {
|
|
|
if (!(await isChipSelected(dry))) {
|
|
|
await dry.click({ force: true });
|
|
|
console.log("[flock-form] 恢复 Dry Van(避免仅冷藏车阻断)");
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(400);
|
|
|
}
|
|
|
|
|
|
await applyLoggedInScheduling(page, input);
|
|
|
|
|
|
if (input.callBeforePickup === true) {
|
|
|
// Quick/小批量:官网「提货前致电」为灰色,禁止强点
|
|
|
const box = page
|
|
|
.getByRole("checkbox", {
|
|
|
name: /Carrier please call before pickup/i,
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
const disabled = await box.isDisabled().catch(() => true);
|
|
|
if (!disabled) {
|
|
|
await setCheckbox(page, /Carrier please call before pickup/i, true);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[flock-form] 跳过提货前致电(总重 ≤5000 / checkbox disabled)",
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
} else if (input.callBeforePickup === false) {
|
|
|
const box = page
|
|
|
.getByRole("checkbox", {
|
|
|
name: /Carrier please call before pickup/i,
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
const disabled = await box.isDisabled().catch(() => true);
|
|
|
if (!disabled) {
|
|
|
await setCheckbox(page, /Carrier please call before pickup/i, false);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
if (input.callBeforeDelivery === true) {
|
|
|
const box = page
|
|
|
.getByRole("checkbox", {
|
|
|
name: /Carrier please call before delivery/i,
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
const disabled = await box.isDisabled().catch(() => true);
|
|
|
if (!disabled) {
|
|
|
await setCheckbox(page, /Carrier please call before delivery/i, true);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[flock-form] 跳过派送前致电(官网当前禁用 / checkbox disabled)",
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
} else if (input.callBeforeDelivery === false) {
|
|
|
const box = page
|
|
|
.getByRole("checkbox", {
|
|
|
name: /Carrier please call before delivery/i,
|
|
|
})
|
|
|
.first();
|
|
|
if ((await flockLocatorCount(box)) > 0) {
|
|
|
const disabled = await box.isDisabled().catch(() => true);
|
|
|
if (!disabled) {
|
|
|
await setCheckbox(page, /Carrier please call before delivery/i, false);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 填写登录后 Quick 查价表单 */
|
|
|
export async function fillFlockLoggedInQuoteForm(
|
|
|
page: Page,
|
|
|
input: LoggedInFormInput,
|
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
|
try {
|
|
|
return await fillFlockLoggedInQuoteFormInner(page, input);
|
|
|
} catch (error) {
|
|
|
if (isFlockPageClosedError(error)) {
|
|
|
return { ok: false, error: flockPageClosedMessage("填写 Quick 表单") };
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fillFlockLoggedInQuoteFormInner(
|
|
|
page: Page,
|
|
|
input: LoggedInFormInput,
|
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
|
await ensureQuickMode(page);
|
|
|
|
|
|
if (!(await fillLoggedInPickupDate(page, input.pickupDate))) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: "QUOTE_ENTRY_UNAVAILABLE:无法填写 Shipment Pickup 日期",
|
|
|
};
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
if (
|
|
|
!(await fillTextbox(
|
|
|
page,
|
|
|
[/Pickup ZIP Code/i, /Pickup ZIP/i],
|
|
|
input.pickupZip,
|
|
|
))
|
|
|
) {
|
|
|
return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法定位取件 ZIP" };
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const pickupTypes = [
|
|
|
...flockLocationRpaLabels(input.pickupLocationType ?? "business_with_dock"),
|
|
|
];
|
|
|
if (
|
|
|
!(await selectFlockComboboxOption(page, /Pickup Type/i, pickupTypes))
|
|
|
) {
|
|
|
return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法选择 Pickup Type" };
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
if (
|
|
|
!(await fillTextbox(
|
|
|
page,
|
|
|
[/Delivery ZIP Code/i, /Delivery ZIP/i],
|
|
|
input.deliveryZip,
|
|
|
))
|
|
|
) {
|
|
|
return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法定位派送 ZIP" };
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const deliveryTypes = [
|
|
|
...flockLocationRpaLabels(
|
|
|
input.deliveryLocationType ?? "business_with_dock",
|
|
|
),
|
|
|
];
|
|
|
if (
|
|
|
!(await selectFlockComboboxOption(page, /Delivery Type/i, deliveryTypes))
|
|
|
) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: "QUOTE_ENTRY_UNAVAILABLE:无法选择 Delivery Type",
|
|
|
};
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
// 地点类型附属(Residential/Farm 等):对齐官网勾选项
|
|
|
await applyLoggedInLocationAccessorials(page, input);
|
|
|
await pause(page);
|
|
|
|
|
|
if (
|
|
|
!(await fillTextbox(page, [/Quantity/i], String(input.palletCount)))
|
|
|
) {
|
|
|
return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法填写 Quantity" };
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const packaging = [
|
|
|
...flockPackagingRpaLabels(input.packagingType ?? "pallets_48x40"),
|
|
|
];
|
|
|
if (
|
|
|
!(await selectFlockComboboxOption(page, /Packaging Type/i, packaging))
|
|
|
) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: "QUOTE_ENTRY_UNAVAILABLE:无法选择 Packaging Type",
|
|
|
};
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
for (const [names, value, label] of [
|
|
|
[[/Length \(in\)/i, /^Length/i], String(input.lengthIn), "长度"],
|
|
|
[[/Width \(in\)/i, /^Width/i], String(input.widthIn), "宽度"],
|
|
|
[[/Height \(in\)/i, /^Height/i], String(input.heightIn), "高度"],
|
|
|
] as const) {
|
|
|
// Bags 等需长宽高;部分 Pallet 选项可能无独立 Length(录制中 Bags 有)
|
|
|
const filled = await fillTextbox(page, [...names], value);
|
|
|
if (!filled && label === "高度") {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: `QUOTE_ENTRY_UNAVAILABLE:无法定位${label}`,
|
|
|
};
|
|
|
}
|
|
|
if (filled) {
|
|
|
await pause(page);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
!(await fillTextbox(
|
|
|
page,
|
|
|
[/Total Weight \(lbs\)/i, /Total Weight/i, /Total shipment weight/i],
|
|
|
String(input.totalWeightLb),
|
|
|
))
|
|
|
) {
|
|
|
return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法填写 Total Weight" };
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const freightLabels = [
|
|
|
...flockFreightClassRpaLabels(input.freightClass ?? "density"),
|
|
|
];
|
|
|
if (
|
|
|
!(await selectFlockComboboxOption(page, /Freight Class/i, freightLabels))
|
|
|
) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: "QUOTE_ENTRY_UNAVAILABLE:无法选择 Freight Class",
|
|
|
};
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
const description = input.description?.trim() || DEFAULT_DESCRIPTION;
|
|
|
if (
|
|
|
!(await fillTextarea(
|
|
|
page,
|
|
|
[/Description of Contents/i, /Description/i],
|
|
|
description,
|
|
|
))
|
|
|
) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
error: "QUOTE_ENTRY_UNAVAILABLE:无法填写 Description of Contents",
|
|
|
};
|
|
|
}
|
|
|
await pause(page);
|
|
|
|
|
|
// Options:只点一次,禁止 checkbox + 文案双击导致取消
|
|
|
await ensureOptionToggle(page, "Stackable", Boolean(input.stackable));
|
|
|
await ensureOptionToggle(page, "Turnable", Boolean(input.turnable));
|
|
|
await pause(page);
|
|
|
|
|
|
await applyLoggedInServicesAndVehicles(page, input);
|
|
|
|
|
|
return { ok: true };
|
|
|
}
|
|
|
|
|
|
export async function clickFlockLoggedInQuoteSubmit(
|
|
|
page: Page,
|
|
|
): Promise<boolean> {
|
|
|
const byTestId = page.getByTestId("quote-request-quick-form__submit_button");
|
|
|
if (
|
|
|
(await flockLocatorCount(byTestId)) > 0 &&
|
|
|
(await byTestId.first().isVisible().catch(() => false))
|
|
|
) {
|
|
|
const btn = byTestId.first();
|
|
|
// 车型/校验红字时按钮 disable,最多等 15s
|
|
|
try {
|
|
|
await btn.waitFor({ state: "visible", timeout: 5_000 });
|
|
|
await page
|
|
|
.waitForFunction(
|
|
|
(el) => {
|
|
|
const b = el as HTMLButtonElement;
|
|
|
return !b.disabled && !b.classList.contains("Mui-disabled");
|
|
|
},
|
|
|
await btn.elementHandle(),
|
|
|
{ timeout: 15_000 },
|
|
|
)
|
|
|
.catch(() => undefined);
|
|
|
await btn.click({ timeout: 5_000 });
|
|
|
return true;
|
|
|
} catch (err) {
|
|
|
if (isFlockPageClosedError(err)) return false;
|
|
|
console.warn(
|
|
|
`[flock-form] 提交按钮不可用: ${
|
|
|
err instanceof Error ? err.message.slice(0, 120) : err
|
|
|
}`,
|
|
|
);
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
const candidates = [
|
|
|
page.getByRole("button", { name: /Generate my quote/i }),
|
|
|
page.getByRole("button", { name: /Get (a )?quote|Submit|Request quote/i }),
|
|
|
];
|
|
|
for (const loc of candidates) {
|
|
|
if (
|
|
|
(await flockLocatorCount(loc)) > 0 &&
|
|
|
(await loc.first().isVisible().catch(() => false))
|
|
|
) {
|
|
|
await loc.first().click();
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|