/** * MotherShip 鐧诲綍鎬?dashboard銆孋reate a new shipment銆嶁啋 rate-card 鏌ヤ环 * 鏈夎处瀵嗘椂绂佹璧板尶鍚?Axel Direct锛堜环闈㈡槸 TForce/Daylight 妗d綅锛岄潪 ABF/XPO Direct锛? */ import type { Page } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { launchRpaBrowser } from "@/lib/rpa/browser-launch"; import { resolveStorageStatePath } from "@/lib/axel/session"; import { getEffectiveMothershipLogin, hasEffectiveMothershipLogin, } from "@/lib/rpa/mothership-login-context"; import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider"; import { PROVIDER_LOGIN_FAILED_USER_MESSAGE } from "@/modules/rpa/provider-login-message"; import { RpaError } from "@/modules/rpa/errors"; import { isMothershipWeightEachAllowed, MOTHERSHIP_MAX_WEIGHT_EACH_LB, normalizeMothershipReadyDateIso, snapMothershipReadyDateToWeekday, } from "@/lib/mothership/logged-in-constraints"; import { extractMothershipAccessorialBlockMessages, formatMothershipPortalQuoteMessage, parseMothershipPortalQuoteMessageFromBody, } from "@/lib/mothership/portal-quote-messages"; import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator"; import { createContext, withRpaSessionLock, } from "@/workers/rpa/session-manager"; /** 登录后一级查价稳定入口(录制 mothership-logged-in-20260715-140935.js) */ export const MOTHERSHIP_CREATE_SHIPMENT_URL = "https://dashboard.mothership.com/ship"; /** Continue 后等到价卡:含 Inbox 关闭 / Save&update / 慢车道 */ const RATE_WAIT_MS = 120_000; /** Continue 后先等一级自然出价,避免加载期误判缺二级必填 */ const RATE_GRACE_AFTER_CONTINUE_MS = 25_000; const FIELD_WAIT_MS = 25_000; /** 濉〃寰仠锛氬敖閲忕煭锛岄潬 waitFor 鑰岄潪鐩茬瓑 */ const PAUSE_XS = 80; const PAUSE_SM = 150; const PAUSE_MD = 280; const SUGGEST_WAIT_MS = 1_200; export function parseLoggedInRateCardText(text: string): QuoteItem | null { const compact = text.replace(/\s+/g, " ").trim(); if (!compact) return null; const priceMatch = compact.match(/\$\s*([0-9,]+\.\d{2})/); if (!priceMatch) return null; const total = Number(priceMatch[1]!.replace(/,/g, "")); if (!(Number.isFinite(total) && total > 0)) return null; // 为何:官网文案多样;解析失败时不得写 "—"(validateQuoteSchema 会报「时效为空」) const daysMatch = compact.match(/(\d+)\s*-\s*(\d+)\s*business\s*days?/i) || compact.match(/(\d+)\s*business\s*days?/i) || compact.match(/Est\.?\s*(\d+)/i) || compact.match(/Estimated\s+(\d+)/i) || compact.match(/(\d+)\s*个?工作日/) || compact.match(/(\d+)\s*-\s*(\d+)\s*days?/i) || compact.match(/(\d+)\s*days?(?!\s*ago)/i); const days = daysMatch ? daysMatch[2] ? `${daysMatch[1]}-${daysMatch[2]}` : daysMatch[1]! : "待确认"; let carrier = "MotherShip"; const direct = compact.match( /(?:Selected|Checkmark|已选)?\s*([A-Za-z0-9][A-Za-z0-9 &.+/-]{0,30}?)\s+Direct\b/i, ); if (direct?.[1]) { carrier = `${direct[1].trim()} Direct`; } else { const interline = compact.match( /([A-Za-z0-9][A-Za-z0-9 &.+/-]{0,40}?)\s+Interline\b/i, ); if (interline?.[1]) { carrier = `${interline[1].trim()} Interline`; } else { const selected = compact.match( /(?:Selected|已选)\s+([A-Za-z0-9][A-Za-z0-9 &.+/-]{1,40})/i, ); if (selected?.[1]) carrier = selected[1].trim(); } } const guaranteed = /guaranteed|保证送达/i.test(compact); return { serviceLevel: guaranteed ? "guaranteed" : "standard", rateOption: "bestValue", carrier, transitDays: String(days), transitDescription: days === "待确认" ? "时效待定" : `${days} business days`, rawFreight: total, surcharges: 0, rawTotal: total, }; } export function buildLoggedInAddressSearchQuery( addr: QuoteRequest["pickup"], ): string { const stripUnitNoise = (s: string) => s .replace( /\b(?:floor|fl|suite|ste|unit|apt|rm|room)\s*#?\s*[\w-]+\b/gi, "", ) .replace(/\s{2,}/g, " ") .replace(/\s+,/g, ",") .trim(); const street = stripUnitNoise(addr.street?.trim() || ""); const structured = [street, addr.city, addr.state, addr.zip] .map((p) => p?.trim()) .filter(Boolean) .join(", "); if (street && addr.city?.trim() && addr.state?.trim()) { return structured; } const fallback = addr.mothershipDisplayLabel?.trim() || addr.formattedAddress?.trim() || structured; return stripUnitNoise(fallback); } function addressQuery(addr: QuoteRequest["pickup"]): string { return buildLoggedInAddressSearchQuery(addr); } /** dashboard 鍏ㄥ眬銆孲earch any shipment銆嶉伄缃╋紙璇Е Search / 蹇嵎閿細寮瑰嚭锛?*/ export async function isGlobalShipmentSearchModalOpen( page: Page, ): Promise { const markers = [ page.getByRole("heading", { name: /Search any shipment/i }), page.getByPlaceholder(/Search any shipment/i), page.getByText(/Search by shipment#.*business name/i), ]; for (const loc of markers) { if ((await loc.count()) > 0 && (await loc.first().isVisible())) { return true; } } return false; } export async function dismissGlobalShipmentSearchModal( page: Page, ): Promise { if (!(await isGlobalShipmentSearchModalOpen(page))) { return false; } console.log("[rpa] logged-in: 鍏抽棴 Search any shipment 鍏ㄥ眬寮圭獥"); await page.keyboard.press("Escape"); await page.waitForTimeout(PAUSE_SM); if (!(await isGlobalShipmentSearchModalOpen(page))) { return true; } const closeCandidates = [ page.getByRole("button", { name: /^close$/i }), page.getByRole("button", { name: /close dialog/i }), page.locator('button[aria-label*="close" i]'), page.getByRole("button").filter({ hasText: /^×$|^X$/ }), ]; for (const btn of closeCandidates) { if ((await btn.count()) > 0) { try { await btn.first().click({ timeout: 2_000 }); await page.waitForTimeout(PAUSE_SM); if (!(await isGlobalShipmentSearchModalOpen(page))) { return true; } } catch { /* try next */ } } } await page.keyboard.press("Escape"); await page.waitForTimeout(PAUSE_SM); return !(await isGlobalShipmentSearchModalOpen(page)); } async function isLoginFormVisible(page: Page): Promise { const email = page.getByTestId("auth-email-input"); if ((await email.count()) === 0) return false; return email.first().isVisible().catch(() => false); } /** goto /ship 后等 SPA 落定:表单 or 登录页(避免重定向前误跳过登录) */ async function waitForShipOrLogin( page: Page, timeoutMs = 20_000, ): Promise<"form" | "login" | "unknown"> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isCreateShipmentFormVisible(page)) return "form"; const url = page.url().toLowerCase(); if ( url.includes("/login") || url.includes("/sign-in") || (await isLoginFormVisible(page)) ) { return "login"; } await page.waitForTimeout(200); } if (await isCreateShipmentFormVisible(page)) return "form"; const url = page.url().toLowerCase(); if ( url.includes("/login") || url.includes("/sign-in") || (await isLoginFormVisible(page)) ) { return "login"; } return "unknown"; } async function loginDashboardIfNeeded(page: Page): Promise { const url = page.url().toLowerCase(); const onLoginUrl = url.includes("/login") || url.includes("/sign-in"); // 为何:会话过期时 URL 可能仍停在 /ship,仅靠 path 会跳过登录 if (!onLoginUrl && !(await isLoginFormVisible(page))) { return; } const creds = getEffectiveMothershipLogin(); if (!creds) { throw new RpaError( "STRUCT_CHANGE", "dashboard 登录页但无 MotherShip 账密(请管理端配置客户账密,或 .env 填 MOTHERSHIP_EMAIL/PASSWORD,或上传 mothership-logged-in-storage.json)", { retryable: false }, ); } console.log( "[rpa] logged-in: dashboard login email=" + creds.email.slice(0, 2) + "***", ); const email = page.getByTestId("auth-email-input"); const password = page.getByTestId("auth-password-input"); const submit = page.getByTestId("auth-log-in-button"); try { if ((await email.count()) > 0) { await email.waitFor({ state: "visible", timeout: 10_000 }); await email.fill(creds.email); await password.fill(creds.password); await submit.click(); } else { await page.fill( process.env.RPA_SELECTOR_LOGIN_EMAIL ?? 'input[type="email"], input[name="email"]', creds.email, ); await page.fill( process.env.RPA_SELECTOR_LOGIN_PASSWORD ?? 'input[type="password"], input[name="password"]', creds.password, ); await page.click( process.env.RPA_SELECTOR_LOGIN_SUBMIT ?? 'button[type="submit"], button:has-text("Sign in"), button:has-text("Log in")', ); } await page.waitForURL((u) => !/login|sign-in/i.test(u.href), { timeout: 30_000, }); } catch (err) { const brief = err instanceof Error ? err.message : String(err); console.warn( "[rpa] logged-in: dashboard login failed, still on login page. detail=" + brief.slice(0, 200), ); throw new RpaError("PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false, }); } if ( /\/login|sign-in/i.test(page.url()) || (await isLoginFormVisible(page)) ) { throw new RpaError("PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false, }); } } async function isCreateShipmentFormVisible(page: Page): Promise { const pickup = page.getByTestId("quote-create-pickup-input-search"); if ((await pickup.count()) === 0) return false; return pickup.first().isVisible().catch(() => false); } /** 等 SPA 渲染出提货搜索框;失败返回 false(不抛) */ async function waitForCreateShipmentForm( page: Page, timeoutMs = FIELD_WAIT_MS, ): Promise { try { await page .getByTestId("quote-create-pickup-input-search") .waitFor({ state: "visible", timeout: timeoutMs }); return true; } catch { return false; } } async function gotoCreateShipmentUrl(page: Page): Promise { console.log( "[rpa] logged-in: goto create shipment url=" + MOTHERSHIP_CREATE_SHIPMENT_URL, ); await page.goto(MOTHERSHIP_CREATE_SHIPMENT_URL, { waitUntil: "domcontentloaded", timeout: 60_000, }); await dismissGlobalShipmentSearchModal(page); } async function openCreateShipment(page: Page): Promise { await dismissGlobalShipmentSearchModal(page); if (await isCreateShipmentFormVisible(page)) { return; } // 为何:直达 /ship;须等落定到表单或登录页,禁止重定向前跳过登录 await gotoCreateShipmentUrl(page); let state = await waitForShipOrLogin(page); console.log("[rpa] logged-in: after /ship state=" + state + " url=" + page.url()); if (state === "login") { await loginDashboardIfNeeded(page); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); console.log( "[rpa] logged-in: after login state=" + state + " url=" + page.url(), ); } else { await loginDashboardIfNeeded(page); } await dismissGlobalShipmentSearchModal(page); if (state === "form" || (await waitForCreateShipmentForm(page, FIELD_WAIT_MS))) { return; } // 登录后可能落首页,再强制 /ship console.log("[rpa] logged-in: form missing after login, retry /ship"); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); if (state === "login") { await loginDashboardIfNeeded(page); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); } await dismissGlobalShipmentSearchModal(page); if (state === "form" || (await waitForCreateShipmentForm(page, FIELD_WAIT_MS))) { return; } // 兜底:旧路径 Ship → Create a new shipment(短超时) const shipLink = page.getByRole("link", { name: /^Ship$/i }); if ((await shipLink.count()) > 0) { console.log("[rpa] logged-in: fallback click Ship menu"); await shipLink.first().click(); await page.waitForTimeout(PAUSE_MD); await dismissGlobalShipmentSearchModal(page); const create = page.getByText( /Create a new shipment|Create shipment|创建新货件|新建货件/i, ); const menuOk = await create .first() .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (menuOk) { await create.first().click(); await dismissGlobalShipmentSearchModal(page); if (await waitForCreateShipmentForm(page, FIELD_WAIT_MS)) { return; } } } const body = (await page.locator("body").innerText().catch(() => "")).slice( 0, 400, ); const stillLogin = /\/login|sign-in/i.test(page.url()) || /Sign in to Mothership|auth-email-input|Forgot password/i.test(body); if (stillLogin) { throw new RpaError( "PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false }, ); } throw new RpaError( "STRUCT_CHANGE", "无法打开登录态 Create shipment 表单(无 quote-create-pickup-input-search)。url=" + page.url() + " body=" + body.replace(/\s+/g, " ").trim(), { retryable: true }, ); } /** * 校验地址输入框已确认(非占位/非错误状态) * 官网确认后:对应侧「Please enter the address…」占位消失,且 input 含地址 token。 */ async function isAddressConfirmed( page: Page, side: "pickup" | "delivery", tokens: string[], ): Promise { const testId = side === "pickup" ? "quote-create-pickup-input-search" : "quote-create-delivery-input-search"; const body = await page.locator("body").innerText().catch(() => ""); const placeholderRe = side === "pickup" ? /Please enter the address of your pick-?up location/i : /Please enter the address of your delivery location/i; if (placeholderRe.test(body)) { return false; } const inputVal = ( await page .getByTestId(testId) .inputValue() .catch(() => "") ).trim(); if (inputVal.length <= 3) { return false; } // 必须命中至少一个地址 token,避免仅搜索未点选却残留输入 const lower = inputVal.toLowerCase(); return tokens.some((t) => t.length >= 3 && lower.includes(t.toLowerCase())); } async function tryPickOnce( page: Page, side: "pickup" | "delivery", query: string, tokens: string[], t0: number, ): Promise { const testId = side === "pickup" ? "quote-create-pickup-input-search" : "quote-create-delivery-input-search"; const input = page.getByTestId(testId); await input.click({ timeout: 5_000 }); await input.fill(""); await input.fill(query); const searchBtn = page.getByRole("button", { name: new RegExp( tokens[0] ? `Search\\s+.*${tokens[0]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` : "^Search\\s", "i", ), }); const listItem = page.getByTestId("search-list-item"); const appeared = await new Promise<"search" | "list" | "timeout">( (resolve) => { let done = false; const finish = (v: "search" | "list" | "timeout") => { if (done) return; done = true; resolve(v); }; const timer = setTimeout(() => finish("timeout"), SUGGEST_WAIT_MS); searchBtn .first() .waitFor({ state: "visible", timeout: SUGGEST_WAIT_MS }) .then(() => { clearTimeout(timer); finish("search"); }) .catch(() => undefined); listItem .first() .waitFor({ state: "visible", timeout: SUGGEST_WAIT_MS }) .then(() => { clearTimeout(timer); finish("list"); }) .catch(() => undefined); }, ); const clickOpt = { timeout: 3_000, force: true as const }; if ((await listItem.count()) > 0 && (await listItem.first().isVisible())) { await listItem.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: suggest side=" + side + " hint=" + appeared + " via=listItem ms=" + String(Date.now() - t0)); return true; } let clickedSearch = false; if ((await searchBtn.count()) > 0 && (await searchBtn.first().isVisible())) { await searchBtn.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); clickedSearch = true; } if ((await listItem.count()) > 0 && (await listItem.first().isVisible())) { await listItem.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: suggest side=" + side + " hint=" + appeared + " via=search+list ms=" + String(Date.now() - t0)); return true; } // 仅点了 Search、未点选候选:不算确认(避免输入残留导致假阳性) if (clickedSearch) { console.log( "[rpa] logged-in: suggest side=" + side + " via=search-only(无 listItem),不算确认 ms=" + String(Date.now() - t0), ); return false; } const buttons = page.getByRole("button"); const n = await buttons.count(); for (let i = 0; i < Math.min(n, 40); i += 1) { const btn = buttons.nth(i); const name = ((await btn.innerText().catch(() => "")) || "").replace(/\s+/g, " "); if (/^Search any shipment$/i.test(name)) continue; if (/^Search by shipment/i.test(name)) continue; if (/^Search\s/i.test(name) && !tokens.some((t) => name.includes(t))) continue; if (tokens.some((t) => name.toLowerCase().includes(t.toLowerCase()))) { await btn.click(clickOpt); console.log("[rpa] logged-in: suggest side=" + side + " via=tokenBtn ms=" + String(Date.now() - t0)); return true; } } return false; } async function pickAddressSuggestion( page: Page, side: "pickup" | "delivery", query: string, ): Promise { const t0 = Date.now(); // 先关 Inbox 抽屉,否则 input/Search 点击会空等默认 30s await closeInboxDrawer(page); const tokens = query .split(/[,\s]+/) .map((t) => t.trim()) .filter((t) => t.length >= 3) .slice(0, 3); // 最多 2 次:首次 + 失败后清空重试 for (let attempt = 0; attempt < 2; attempt += 1) { if (attempt > 0) { console.log("[rpa] logged-in: 地址未确认,重试 side=" + side + " attempt=" + String(attempt + 1)); await closeInboxDrawer(page); } await tryPickOnce(page, side, query, tokens, t0); await page.waitForTimeout(PAUSE_SM); // 校验地址已真正 commit if (await isAddressConfirmed(page, side, tokens)) { console.log( "[rpa] logged-in: address confirmed side=" + side + " ms=" + String(Date.now() - t0), ); return; } console.log("[rpa] logged-in: address NOT confirmed side=" + side + " attempt=" + String(attempt + 1)); } throw new RpaError( "ADDRESS_SUGGESTION_NOT_FOUND", "登录态地址未确认(官网表单仍空)(" + side + "):" + query.slice(0, 80), { retryable: true }, ); } /** 附加服务 id → 官网可见英文(下拉文案兜底) */ const ACCESSORIAL_LABEL_RE: Record = { cfs: /\bCFS\b/i, liftgate: /liftgate/i, limitedAccess: /limited\s*access/i, inside: /inside/i, residential: /residential/i, tradeshow: /trade\s*show|tradeshow/i, appointment: /appointment/i, fbaAppointment: /fba|amazon.*appointment|appointment.*amazon/i, }; /** 部分官网 testId 与前端 id 不完全一致时的别名 */ const ACCESSORIAL_TESTID_ALIASES: Record = { appointment: ["appointment", "deliveryAppointment", "DeliveryAppointment"], fbaAppointment: ["fbaAppointment", "fba", "amazonAppointment"], limitedAccess: ["limitedAccess", "limited-access", "limited_access"], }; async function listVisibleAccessorialOptionIds(page: Page): Promise { return page .locator("[data-testid^='address-book-accessorials-option-']") .evaluateAll((els) => els .map((el) => el.getAttribute("data-testid") ?? "") .map((id) => id.replace(/^address-book-accessorials-option-/, "")) .filter(Boolean), ) .catch(() => [] as string[]); } async function openAccessorialDropdown( page: Page, side: "pickup" | "delivery", ): Promise { if (side === "pickup") { const trigger = page.getByTestId("address-book-accessorials-trigger").first(); await trigger.waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await trigger.click(); } else { const deliveryOpen = page.getByRole("button", { name: /Select\s+Chevron\s*down/i, }); if ((await deliveryOpen.count()) > 0) { await deliveryOpen.last().click(); } else { const triggers = page.getByTestId("address-book-accessorials-trigger"); const n = await triggers.count(); await triggers.nth(Math.max(0, n - 1)).click(); } } await page.waitForTimeout(PAUSE_SM); } async function clickAccessorialOption( page: Page, side: "pickup" | "delivery", id: string, ): Promise { const aliases = ACCESSORIAL_TESTID_ALIASES[id] ?? [id]; for (const alias of aliases) { const byTestId = page.getByTestId(`address-book-accessorials-option-${alias}`); const count = await byTestId.count().catch(() => 0); if (count > 0) { const target = byTestId.first(); await target.scrollIntoViewIfNeeded().catch(() => undefined); const visible = await target.isVisible().catch(() => false); if (visible) { await target.click({ timeout: 5_000 }); return; } // 在 DOM 但不可见:下拉动画未完成时 force 点击 await target.click({ force: true, timeout: 5_000 }); return; } } const labelRe = ACCESSORIAL_LABEL_RE[id]; if (labelRe) { const byText = page .locator("[data-testid^='address-book-accessorials-option-']") .filter({ hasText: labelRe }); if ((await byText.count().catch(() => 0)) > 0) { await byText.first().click({ timeout: 5_000 }); return; } const roleOpt = page.getByRole("option", { name: labelRe }); if ((await roleOpt.count().catch(() => 0)) > 0) { await roleOpt.first().click({ timeout: 5_000 }); return; } const roleMenuitem = page.getByRole("menuitemcheckbox", { name: labelRe }); if ((await roleMenuitem.count().catch(() => 0)) > 0) { await roleMenuitem.first().click({ timeout: 5_000 }); return; } } const available = await listVisibleAccessorialOptionIds(page); const sideZh = side === "pickup" ? "提货" : "派送"; throw new RpaError( "CARRIER_NO_CAPACITY", `${sideZh}附加服务「${id}」在官网下拉中不可用或未展开(可见选项:${available.slice(0, 12).join(",") || "无"})。请取消该附加服务后重试,或确认地址类型是否支持。`, { retryable: false }, ); } async function applyAccessorials( page: Page, side: "pickup" | "delivery", ids: string[] | undefined, ): Promise { const wanted = [ ...new Set((ids ?? []).map((id) => id.trim()).filter(Boolean)), ]; if (wanted.length === 0) { console.log(`[rpa] logged-in: 跳过附加服务 side=${side}(未勾选)`); return; } await openAccessorialDropdown(page, side); // 等首个 option 出现;没有则重开一次 const anyOption = page.locator("[data-testid^='address-book-accessorials-option-']"); const appeared = await anyOption .first() .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (!appeared) { console.log(`[rpa] logged-in: 附加服务下拉未展开,重试 side=${side}`); await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(PAUSE_SM); await openAccessorialDropdown(page, side); await anyOption.first().waitFor({ state: "visible", timeout: 10_000 }).catch(() => { throw new RpaError( "STRUCT_CHANGE", `${side === "pickup" ? "提货" : "派送"}附加服务下拉未能打开,请稍后重试`, { retryable: true }, ); }); } for (const id of wanted) { await clickAccessorialOption(page, side, id); await page.waitForTimeout(PAUSE_XS); console.log(`[rpa] logged-in: 勾选附加服务 side=${side} id=${id}`); } const closeBtn = page.getByRole("button", { name: /\d+\s+selected.*Chevron/i, }); if ((await closeBtn.count()) > 0 && (await closeBtn.first().isVisible())) { await closeBtn.first().click(); } else { await page.keyboard.press("Escape"); } await page.waitForTimeout(PAUSE_SM); console.log( `[rpa] logged-in: 完成附加服务 side=${side} ids=${wanted.join(",")}`, ); } /** YYYY-MM-DD 鈫?鏃ュ巻鏍煎瓙鍚嶃€孴hu Jul 16銆嶏紙褰曞埗 gridcell锛屾棤搴忔暟璇嶏級 */ export function formatLoggedInReadyGridCell(isoDate: string): string { const d = new Date(`${isoDate}T12:00:00`); if (Number.isNaN(d.getTime())) { return isoDate; } const weekday = d.toLocaleDateString("en-US", { weekday: "short" }); const month = d.toLocaleDateString("en-US", { month: "short" }); return `${weekday} ${month} ${d.getDate()}`; } /** 鎵撳紑鏃ュ巻鍚庣偣鐩爣鏃ワ細瀹樼綉鏃ユ牸甯镐綆瀵规瘮搴︼紝wait visible 浼氳秴鏃讹紝闇€ force */ async function clickReadyDateInCalendar( page: Page, isoDate: string, ): Promise { const d = new Date(`${isoDate}T12:00:00`); if (Number.isNaN(d.getTime())) { throw new RpaError("RPA_DATA_INVALID", `可提货日无效: ${isoDate}`, { retryable: false, }); } const day = d.getDate(); const cellName = formatLoggedInReadyGridCell(isoDate); const monthLong = d.toLocaleDateString("en-US", { month: "long" }); const year = d.getFullYear(); // 鍒楀嚭褰撳墠鏃ュ巻鏍硷紝渚夸簬鎺掓煡鍚嶇О婕傜Щ const named = await page .locator('[role="gridcell"], [role="grid"] button') .evaluateAll((els) => els.slice(0, 40).map((el) => ({ role: el.getAttribute("role"), name: (el.getAttribute("aria-label") || el.textContent || "") .replace(/\s+/g, " ") .trim() .slice(0, 80), disabled: el.getAttribute("aria-disabled") === "true" || (el as HTMLButtonElement).disabled === true, })), ) .catch(() => [] as Array<{ role: string | null; name: string; disabled: boolean }>); if (named.length) { console.log( `[rpa] logged-in: 鏃ュ巻鏍奸噰鏍?${JSON.stringify(named.filter((n) => n.name).slice(0, 12))}`, ); } const candidates = [ page.getByRole("gridcell", { name: cellName }), page.getByRole("gridcell", { name: new RegExp( `${cellName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i", ), }), page.getByRole("button", { name: new RegExp(`${monthLong}\\s+${day}(st|nd|rd|th)?,?\\s*${year}`, "i"), }), page.getByRole("gridcell", { name: new RegExp(`\\b${day}(st|nd|rd|th)?\\b`), }), page.getByRole("button", { name: new RegExp(`^${day}$`) }), ]; for (const loc of candidates) { const n = await loc.count(); for (let i = 0; i < Math.min(n, 8); i += 1) { const el = loc.nth(i); const attached = await el .waitFor({ state: "attached", timeout: 1_500 }) .then(() => true) .catch(() => false); if (!attached) continue; const disabled = (await el.getAttribute("aria-disabled").catch(() => null)) === "true" || (await el.isDisabled().catch(() => false)); if (disabled) continue; const label = ( (await el.getAttribute("aria-label").catch(() => null)) || (await el.innerText().catch(() => "")) || "" ).replace(/\s+/g, " "); // 官网禁选周末:跳过 Sat/Sun 格子 if (/\b(Sat|Sun)\b/i.test(label)) continue; // 鏃ュ彿鍖归厤鏃堕伩鍏嶈鐐瑰叾瀹冩湀娈嬬暀鏍硷細浼樺厛鍚洰鏍囨棩鏂囨 if ( !label.includes(String(day)) && !new RegExp(`\\b${day}\\b`).test(label) && label !== String(day) ) { continue; } await el.click({ force: true }); await page.waitForTimeout(PAUSE_SM); console.log( "[rpa] logged-in: selected ready-date " + isoDate + " via=" + JSON.stringify(label || cellName), ); return; } } throw new RpaError( "RPA_DATA_INVALID", "登录态日历未点到 " + isoDate + " want=" + cellName + " sample=" + named .map((x) => x.name) .filter(Boolean) .slice(0, 8) .join("|"), { retryable: true }, ); } /** * Ready date/time dropdown helpers (recording 140935). * Time trigger a11y name is the CURRENT truncated value (e.g. ":00 AM"), * not the target — do not filter by target AM/PM (hits notification items). */ async function openReadyTimeDropdown(page: Page): Promise { const afterLabel = page.getByText("after", { exact: true }); if ((await afterLabel.count()) > 0) { await afterLabel.first().click().catch(() => undefined); await page.waitForTimeout(150); } const buttons = page.getByRole("button", { name: /:\d{2}\s*(AM|PM)/i, }); const n = await buttons.count(); for (let i = 0; i < n; i += 1) { const btn = buttons.nth(i); const testId = (await btn.getAttribute("data-testid").catch(() => null)) ?? ""; if (testId.startsWith("notification-") || testId.includes("notification")) { continue; } const label = ( (await btn.getAttribute("aria-label").catch(() => null)) || (await btn.innerText().catch(() => "")) || "" ).replace(/\s+/g, " "); // 鎷掓帀閫氱煡鏂囨锛涘彧瑕佸儚銆?:00 AM銆?銆?00 AM銆嶇殑鐭椂鍒婚挳 if (/reschedule|shipment|notification/i.test(label)) continue; if (!/:\d{2}\s*(AM|PM)/i.test(label) && !/^\s*\d{1,2}:\d{2}\s*(AM|PM)\s*$/i.test(label)) { continue; } if (!(await btn.isVisible().catch(() => false))) continue; await btn.click(); console.log( "[rpa] logged-in: open ready-time trigger=" + JSON.stringify(label.slice(0, 40)), ); return; } // 鍏滃簳锛歛fter 鍚庣殑绗竴涓煭鏃跺埢鎸夐挳 const nearby = page .locator("button") .filter({ hasText: /^\s*\d{1,2}:\d{2}\s*(AM|PM)\s*$/i }); const nearbyCount = await nearby.count(); for (let i = 0; i < nearbyCount; i += 1) { const btn = nearby.nth(i); const testId = (await btn.getAttribute("data-testid").catch(() => null)) ?? ""; if (testId.startsWith("notification-")) continue; if (!(await btn.isVisible().catch(() => false))) continue; await btn.click(); console.log("[rpa] logged-in: step"); return; } throw new RpaError( "RPA_DATA_INVALID", "登录态未找到 Ready for pick-up 时刻触发钮", { retryable: true }, ); } async function applyReadyDateTime( page: Page, readyDate: string | undefined, readyTime: string | undefined, ): Promise { if (readyDate?.trim()) { let iso = normalizeMothershipReadyDateIso(readyDate.trim()) ?? readyDate.trim(); if (iso !== readyDate.trim()) { console.log( `[rpa] logged-in: ready_date weekend ${readyDate.trim()} → weekday ${iso}`, ); } const dateBtn = page.getByRole("button", { name: /(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(st|nd|rd|th)?,?\s*20\d{2}.*Chevron/i, }); await dateBtn.first().waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await dateBtn.first().click(); await page.waitForTimeout(PAUSE_MD); await clickReadyDateInCalendar(page, iso); } else { console.log("[rpa] logged-in: skip ready_date (none in request)"); } if (readyTime?.trim()) { const timeLabel = readyTime .trim() .replace(/\uFF1A/g, ":") .replace(/\s+/g, " ") .replace(/([AP])\s*M/i, (_: string, ap: string) => ap.toUpperCase() + "M"); await openReadyTimeDropdown(page); await page.waitForTimeout(PAUSE_SM); const optionExact = page.getByRole("option", { name: timeLabel, exact: true, }); if ((await optionExact.count()) > 0) { await optionExact.first().click(); } else { const escaped = timeLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const optionLoose = page.getByRole("option", { name: new RegExp("^\\s*" + escaped + "\\s*$", "i"), }); await optionLoose.first().waitFor({ state: "visible", timeout: 8_000 }); await optionLoose.first().click(); } await page.waitForTimeout(PAUSE_XS); console.log( "[rpa] logged-in: selected ready-time option=" + JSON.stringify(timeLabel), ); } else { console.log("[rpa] logged-in: skip ready_time (none in request)"); } } /** 鐧诲綍鎬?cargo type id 鈫?瀹樼綉涓嬫媺鑻辨枃鍚嶏紙褰曞埗閫?Pallet/Box锛?*/ const MS_CARGO_TYPE_EN: Record = { pallet: "Pallet", box: "Box", crate: "Crate", piece: "Piece", bale: "Bale", bucket: "Bucket", carton: "Carton", case: "Case", coil: "Coil", cylinder: "Cylinder", drum: "Drum", pail: "Pail", reel: "Reel", roll: "Roll", skid: "Skid", tote: "Tote", tube: "Tube", }; async function selectCargoTypeAt( page: Page, index: number, cargoTypeId: string, ): Promise { const label = MS_CARGO_TYPE_EN[cargoTypeId] ?? "Pallet"; const typeInput = page.getByTestId("cargo-type-dropdown-input").nth(index); await typeInput.waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await typeInput.click(); await page.waitForTimeout(PAUSE_SM); const option = page.getByRole("option", { name: new RegExp(`^${label}$`, "i"), }); if ((await option.count()) > 0) { await option.first().click(); } else { // 褰曞埗锛歭ocator('div').filter({ hasText: /^Pallet$/ }).nth(4) const divOpt = page.locator("div").filter({ hasText: new RegExp(`^${label}$`) }); const n = await divOpt.count(); await divOpt.nth(Math.min(Math.max(n - 1, 0), 4)).click(); } await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: cargo type row=" + index + " type=" + label); } /** Multi-row cargo: fill row0 then cargo-add-button for more rows. */ async function fillCargo(page: Page, req: QuoteRequest): Promise { const lines = req.cargoLines && req.cargoLines.length > 0 ? req.cargoLines : [ { cargoType: "pallet", quantity: req.palletCount || 1, weightLb: req.weightLb, lengthIn: req.dimsIn.l, widthIn: req.dimsIn.w, heightIn: req.dimsIn.h, }, ]; console.log( `[rpa] logged-in: 濉揣鐗╄鏁?${lines.length} types=${lines.map((l) => l.cargoType).join(",")}`, ); for (let i = 0; i < lines.length; i += 1) { if (i > 0) { const addBtn = page.getByTestId("cargo-add-button"); await addBtn.waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await addBtn.click(); await page.waitForTimeout(PAUSE_MD); console.log("[rpa] logged-in: step"); } const line = lines[i]!; if (!isMothershipWeightEachAllowed(line.weightLb)) { throw new RpaError( "RPA_DATA_INVALID", `登录态单件重量不得超过 ${MOTHERSHIP_MAX_WEIGHT_EACH_LB} lb(当前 ${line.weightLb})`, { retryable: false }, ); } await selectCargoTypeAt(page, i, line.cargoType); await page .getByTestId("cargo-quantity-input") .nth(i) .fill(String(Math.max(1, Math.round(line.quantity)))); await page .getByTestId("cargo-weight-input") .nth(i) .fill(String(Math.round(line.weightLb))); await page .getByTestId("cargo-length-input") .nth(i) .fill(String(Math.round(line.lengthIn))); await page .getByTestId("cargo-width-input") .nth(i) .fill(String(Math.round(line.widthIn))); await page .getByTestId("cargo-height-input") .nth(i) .fill(String(Math.round(line.heightIn))); console.log( `[rpa] logged-in: 宸插~璐х墿 row=${i} qty=${line.quantity} wt=${line.weightLb} dims=${line.lengthIn}x${line.widthIn}x${line.heightIn}`, ); } } async function fillLabeledTextNth( page: Page, label: RegExp, index: number, value: string | undefined, ): Promise { const text = (value ?? "").trim(); if (!text) return; const boxes = page.getByRole("textbox", { name: label }); const loc = (await boxes.count()) > index ? boxes : page.getByLabel(label); if ((await loc.count()) <= index) return; const target = loc.nth(index); const current = (await target.inputValue().catch(() => "")).trim(); if (current) return; await target.click({ force: true }).catch(() => undefined); await target.fill(text).catch(() => undefined); } /** MotherShip Details 下拉是 combobox/button,不是 native select */ async function selectLabeledOptionNth( page: Page, label: RegExp, index: number, optionText: string | undefined, ): Promise { const want = (optionText ?? "").trim(); if (!want) return; const combos = page.getByRole("combobox", { name: label }); const buttons = page.getByRole("button", { name: label }); const labeled = page.getByLabel(label); let target = (await combos.count()) > index ? combos.nth(index) : (await buttons.count()) > index ? buttons.nth(index) : (await labeled.count()) > index ? labeled.nth(index) : null; if (!target) return; const shown = ( (await target.innerText().catch(() => "")) || (await target.inputValue().catch(() => "")) || "" ) .replace(/\s+/g, " ") .trim(); if (shown && !/^select(\s+time)?$/i.test(shown) && shown.toLowerCase().includes(want.toLowerCase().slice(0, 4))) { return; } // native