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.

1494 lines
46 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.

/**
* 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, isFlockSchedulingAdvancedUnlocked } 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;
}
}
// 为何:条件等待 Quick/表单,替代固定 1s sleep
const quickOrFormDeadline = Date.now() + 3_000;
while (Date.now() < quickOrFormDeadline) {
if (await isFlockLoggedInQuoteForm(page)) {
return true;
}
const quickVisible = await page
.getByTestId("quick-form-toggle")
.or(page.getByRole("button", { name: /^Quick$/i }))
.first()
.isVisible()
.catch(() => false);
if (quickVisible) break;
await page.waitForTimeout(150);
}
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: 2_000 }).catch(() => false)) {
await target.click({ force: true }).catch(() => undefined);
console.log("[flock-login] 已切 Quick");
break;
}
}
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (await isFlockLoggedInQuoteForm(page)) {
return true;
}
await page.waitForTimeout(200);
}
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(200);
// 等下拉 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(200);
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"
| "loadBars"
| "straps"
| "additionalInsurance"
| "shipmentValueUsd"
>;
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 切换后附属行渲染(条件:出现 Liftgate/Pallet 文案,最多 1.2s)
const accDeadline = Date.now() + 1_200;
while (Date.now() < accDeadline) {
const ready = await page
.getByText(/Liftgate|Pallet Jack|Forklift/i)
.first()
.isVisible()
.catch(() => false);
if (ready) break;
await page.waitForTimeout(100);
}
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 schedulingAdvancedUnlocked(
totalWeightLb: number,
totalPieces: number,
): boolean {
return isFlockSchedulingAdvancedUnlocked(totalPieces, totalWeightLb);
}
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]);
}
/** 撑杆/绑带:仅在官网解锁(件数>5 或总重>5000)时可见可填 */
async function fillLoadBarsAndStraps(
page: Page,
input: LoggedInFormInput,
): Promise<void> {
if (
!isFlockSchedulingAdvancedUnlocked(
input.palletCount,
input.totalWeightLb,
)
) {
return;
}
const bars = Math.max(0, Math.floor(Number(input.loadBars) || 0));
const straps = Math.max(0, Math.floor(Number(input.straps) || 0));
const fillByName = async (name: RegExp, value: string): Promise<boolean> => {
const box = page.getByRole("spinbutton", { name }).first();
if ((await flockLocatorCount(box)) > 0) {
await box.fill(value).catch(() => undefined);
return true;
}
const tb = page.getByRole("textbox", { name }).first();
if ((await flockLocatorCount(tb)) > 0) {
await tb.fill(value).catch(() => undefined);
return true;
}
const labeled = page.getByLabel(name).first();
if ((await flockLocatorCount(labeled)) > 0) {
await labeled.fill(value).catch(() => undefined);
return true;
}
return false;
};
const barsFilled = await fillByName(/Load Bars/i, String(bars));
const strapsFilled = await fillByName(/Straps/i, String(straps));
if (barsFilled || strapsFilled) {
console.log(
`[flock-form] loadBars=${bars} straps=${straps} filled=${barsFilled}/${strapsFilled}`,
);
}
}
/** 额外货运险:Yes + 货值 / No */
async function applyLoggedInInsurance(
page: Page,
input: LoggedInFormInput,
): Promise<void> {
const want = input.additionalInsurance === true;
const yes = page
.getByRole("radio", {
name: /Yes, I want additional insurance|additional insurance/i,
})
.first();
const no = page
.getByRole("radio", {
name: /No, I do not want additional insurance|do not want additional/i,
})
.first();
if (want) {
if ((await flockLocatorCount(yes)) > 0) {
await yes.click({ force: true }).catch(() => undefined);
await page.waitForTimeout(300);
}
const value = Number(input.shipmentValueUsd);
if (Number.isFinite(value) && value > 0) {
const box = page
.getByRole("textbox", { name: /Value of shipment|shipment value/i })
.first();
const labeled = page.getByLabel(/Value of shipment/i).first();
const target =
(await flockLocatorCount(box)) > 0
? box
: (await flockLocatorCount(labeled)) > 0
? labeled
: null;
if (target) {
await target.fill(String(value)).catch(() => undefined);
console.log(`[flock-form] insurance value=${value}`);
} else {
console.warn("[flock-form] 未找到货值输入框");
}
}
} else if ((await flockLocatorCount(no)) > 0) {
await no.click({ force: true }).catch(() => undefined);
}
}
/** 调度服务(件数>5 或总重>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 = schedulingAdvancedUnlocked(
input.totalWeightLb,
input.palletCount,
);
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.schedulingPiecesMin} 或总重>${FLOCK_LIMITS.schedulingWeightMinLb},回退 standard_fcfs`,
);
pickupService = "standard_fcfs";
}
if (
FLOCK_DELIVERY_SERVICE_WEIGHT_LOCKED[deliveryService] &&
!weightOk
) {
console.log(
`[flock-form] 派送调度 ${deliveryService} 需件数>${FLOCK_LIMITS.schedulingPiecesMin} 或总重>${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);
await fillLoadBarsAndStraps(page, input);
// 若只剩冷藏车且无温控:把默认厢式/干货加回来,消除阻断提交的红字
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);
await applyLoggedInInsurance(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] 跳过提货前致电(未达件数/重量门槛 / 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,最多等 20s
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: 20_000 },
)
.catch(() => undefined);
const stillDisabled =
(await btn.isDisabled().catch(() => true)) ||
(await btn.evaluate((el) =>
(el as HTMLButtonElement).classList.contains("Mui-disabled"),
).catch(() => true));
if (stillDisabled) {
// 货运等级错档提示常导致按钮不可点:尽量切到 density 建议档
const densWarn = page.getByText(
/Based on our density calculation|Are you sure about freight class/i,
);
if (await densWarn.first().isVisible().catch(() => false)) {
console.warn(
"[flock-form] 检测到货运等级密度提示,尝试改选 density/推算档",
);
await selectFlockComboboxOption(page, /Freight Class/i, [
"Density",
"density",
"85",
"70",
"50",
]).catch(() => false);
await pause(page);
await page
.waitForFunction(
(el) => {
const b = el as HTMLButtonElement;
return !b.disabled && !b.classList.contains("Mui-disabled");
},
await btn.elementHandle(),
{ timeout: 10_000 },
)
.catch(() => undefined);
}
}
const enabledNow = !(await btn.isDisabled().catch(() => true));
if (!enabledNow) {
console.warn("[flock-form] Generate my quote 仍禁用,放弃假点击");
return false;
}
await btn.scrollIntoViewIfNeeded().catch(() => undefined);
await btn.click({ timeout: 5_000 });
// 为何:确认已离表单;假点击会让 wait_quote 空等数十秒
await page.waitForTimeout(800);
const stillForm = await isFlockLoggedInQuoteForm(page);
if (stillForm) {
console.warn("[flock-form] 点击后仍在查价表单,再试 force 一次");
await btn.click({ force: true, timeout: 5_000 }).catch(() => undefined);
await page.waitForTimeout(1_200);
}
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))
) {
const target = loc.first();
if (await target.isDisabled().catch(() => false)) continue;
await target.click();
return true;
}
}
return false;
}