import type { Page } from "playwright"; import type { CargoCommitProbe } from "@/workers/rpa/cargo-lock"; /** MotherShip 重量输入框与 API freight.weightLbs 均为单托 lb */ export const CARGO_WEIGHT_TOLERANCE_LB = 1.5; export const CARGO_WEIGHT_TOLERANCE_KG = 0.5; const LB_TO_KG = 2.20462; export type MotherShipWeightDisplay = { displayValue: string; unit: "lb" | "kg"; weightLbPerPallet: number; }; export type ExpectedCargoWeight = MotherShipWeightDisplay & { palletCount: number; }; export type LiveWeightFieldMeta = { placeholder: string; ariaLabel: string; adjacentText: string; unit: "lb" | "kg"; }; /** 仅从单个字段的 placeholder/label/相邻文案推断单位(禁止合并多 spec 字符串) */ export function inferWeightUnitFromFieldHint(hint: string): "lb" | "kg" { const text = hint.trim(); if (!text) { return "lb"; } const hasKg = /(?:\bkg\b|公斤|千克)/i.test(text); const hasLb = /(?:\blbs?\b|磅)/i.test(text); if (hasKg && !hasLb) { return "kg"; } if (hasLb) { return "lb"; } return "lb"; } /** 按页面实际单位生成填入值(API 入参始终为单托 lb) */ export function buildWeightDisplayForUnit( weightLbPerPallet: number, unit: "lb" | "kg", ): MotherShipWeightDisplay { if (unit === "kg") { const kg = Math.round((weightLbPerPallet / LB_TO_KG) * 100) / 100; return { displayValue: String(kg), unit: "kg", weightLbPerPallet, }; } return { displayValue: String(weightLbPerPallet), unit: "lb", weightLbPerPallet, }; } /** * @deprecated 仅单测/回退:必须传入**单个**可见字段 hint,不得传 env 全量 specs */ export function resolveMotherShipWeightDisplay( weightLbPerPallet: number, singleFieldHint: string, ): MotherShipWeightDisplay { const unit = inferWeightUnitFromFieldHint(singleFieldHint); return buildWeightDisplayForUnit(weightLbPerPallet, unit); } /** 读取 widget 内当前可见、可编辑的重量输入框单位指示 */ export async function readLiveWeightFieldMeta( page: Page, widgetSelector: string, ): Promise { return page.evaluate((sel) => { const widget = document.querySelector(sel); if (!widget) { return null; } const inputs = widget.querySelectorAll("input"); for (const inp of inputs) { const visible = inp.offsetParent !== null; const editable = visible && !inp.disabled && inp.readOnly === false && inp.getAttribute("aria-readonly") !== "true"; if (!editable) { continue; } const placeholder = inp.placeholder ?? ""; const ariaLabel = inp.getAttribute("aria-label") ?? ""; const id = inp.id; let adjacentText = ""; if (id) { const label = widget.querySelector(`label[for="${id}"]`); adjacentText = label?.textContent?.trim() ?? ""; } const hint = `${placeholder} ${ariaLabel} ${adjacentText}`; if (!/weight|重量|lbs|lb|kg|公斤|千克|磅/i.test(hint)) { continue; } const hasKg = /(?:\bkg\b|公斤|千克)/i.test(hint); const hasLb = /(?:\blbs?\b|磅)/i.test(hint); let unit: "lb" | "kg" = "lb"; if (hasKg && !hasLb) { unit = "kg"; } return { placeholder, ariaLabel, adjacentText, unit, }; } return null; }, widgetSelector); } /** 按 MotherShip 页面当前重量字段实际单位决定填入值(轮询至字段可见) */ export async function resolveMotherShipWeightDisplayFromPage( page: Page, widgetSelector: string, weightLbPerPallet: number, waitMs = 12_000, ): Promise { const deadline = Date.now() + waitMs; while (Date.now() < deadline) { const live = await readLiveWeightFieldMeta(page, widgetSelector); if (live) { return buildWeightDisplayForUnit(weightLbPerPallet, live.unit); } await page.waitForTimeout(150); } return buildWeightDisplayForUnit(weightLbPerPallet, "lb"); } function parseSummaryWeightLb(text: string): number | null { const patterns = [ /x\s*(\d{2,5})\s*Lbs/i, /(\d{2,5})Lbs/i, /\b(\d{2,5})\s*(?:lbs?|kg|公斤)\b/i, ]; for (const re of patterns) { const m = text.match(re); if (m) { const n = Number(m[1]); if (Number.isFinite(n) && n > 0) { return n; } } } return null; } function summaryWeightMatchesExpected( summaryWeight: number, expected: ExpectedCargoWeight, ): boolean { const perPalletOk = Math.abs(summaryWeight - expected.weightLbPerPallet) <= CARGO_WEIGHT_TOLERANCE_LB; const totalExpected = expected.weightLbPerPallet * expected.palletCount; const totalOk = Math.abs(summaryWeight - totalExpected) <= CARGO_WEIGHT_TOLERANCE_LB * expected.palletCount; if (expected.unit === "kg") { const kg = Number(expected.displayValue); return ( Math.abs(summaryWeight - kg) <= CARGO_WEIGHT_TOLERANCE_KG || Math.abs(summaryWeight - kg * expected.palletCount) <= CARGO_WEIGHT_TOLERANCE_KG * expected.palletCount ); } return perPalletOk || totalOk; } /** widget 内重量/托盘是否与请求一致(单托语义) */ export function probeWeightMatchesExpected( probe: CargoCommitProbe, expected: ExpectedCargoWeight, ): boolean { if ( probe.internalPalletCount !== null && probe.internalPalletCount !== expected.palletCount ) { return false; } if (probe.internalWeight !== null && probe.internalWeight > 0) { if (expected.unit === "kg") { return ( Math.abs(probe.internalWeight - Number(expected.displayValue)) <= CARGO_WEIGHT_TOLERANCE_KG ); } return ( Math.abs(probe.internalWeight - expected.weightLbPerPallet) <= CARGO_WEIGHT_TOLERANCE_LB ); } const summaryWeight = parseSummaryWeightLb(probe.widgetTextPreview); if (summaryWeight === null) { return false; } return summaryWeightMatchesExpected(summaryWeight, expected); } /** session / storageState 残留货物摘要且与本次请求不一致 */ export function isStaleCargoState( probe: CargoCommitProbe, expected: ExpectedCargoWeight, ): boolean { if (!probe.hasSummaryChip) { return false; } return !probeWeightMatchesExpected(probe, expected); }