/** * 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 { const ms = pauseMs(); if (ms > 0) { await page.waitForTimeout(ms); } } /** 是否为登录后 Quick 查价页 */ export async function isFlockLoggedInQuoteForm(page: Page): Promise { 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 { 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, value: string, ): Promise { 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, value: string, ): Promise { 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 { 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(); 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 { 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 { 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 { 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 { 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 { 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 { // 等 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 { 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 { 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(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 { 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 { 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 { 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 { 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 { 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 { 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; }