|
|
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<LiveWeightFieldMeta | null> {
|
|
|
return page.evaluate((sel) => {
|
|
|
const widget = document.querySelector(sel);
|
|
|
if (!widget) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const inputs = widget.querySelectorAll<HTMLInputElement>("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<MotherShipWeightDisplay> {
|
|
|
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);
|
|
|
}
|