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.

1325 lines
40 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 } 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;
}