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.

200 lines
6.6 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.

import type { QuoteRecord } from "@prisma/client";
import type { AlertType } from "@/modules/alert/types";
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
const CARGO_TYPE_ZH: Record<string, string> = {
general_freight: "普通货物",
machinery: "机械设备",
furniture: "家具",
electronics: "电子产品",
building_materials: "建材",
auto_parts: "汽车配件",
food_nonperishable: "非生鲜食品",
apparel: "服装",
other: "其他",
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return null;
}
/** 客户填写的地址(街道/城市/州/邮编) */
export function formatCustomerAddressForAdmin(addr: unknown): string {
const o = asRecord(addr);
if (!o) {
return "—";
}
const formatted =
typeof o.formatted_address === "string" ? o.formatted_address.trim() : "";
const parts = [
typeof o.street === "string" ? o.street.trim() : "",
typeof o.city === "string" ? o.city.trim() : "",
typeof o.state === "string" ? o.state.trim() : "",
typeof o.zip === "string" ? o.zip.trim() : "",
].filter(Boolean);
if (parts.length > 0) {
return parts.join(", ");
}
return formatted || "—";
}
/** MotherShip 联想确认后的精确地址 */
export function formatSelectedAddressForAdmin(addr: unknown): string {
const o = asRecord(addr);
if (!o) {
return "—";
}
const label =
(typeof o.mothership_display_label === "string" &&
o.mothership_display_label.trim()) ||
"";
return label || formatCustomerAddressForAdmin(addr);
}
/** @deprecated 使用 formatSelectedAddressForAdmin */
export function formatAddressForAdmin(addr: unknown): string {
return formatSelectedAddressForAdmin(addr);
}
export function formatCargoForAdmin(
cargo: unknown,
record?: Pick<
QuoteRecord,
"weightLb" | "palletCount" | "cargoType" | "dimLIn" | "dimWIn" | "dimHIn"
> | null,
): string {
const o = asRecord(cargo);
const pallets = record?.palletCount ?? o?.pallet_count;
const cargoType = record?.cargoType ?? o?.cargo_type;
const weightLb =
record?.weightLb != null
? Number(record.weightLb)
: typeof o?.weight_lb === "number"
? o.weight_lb
: null;
const parts: string[] = [];
if (pallets != null) {
parts.push(`${pallets}`);
}
if (weightLb != null && Number.isFinite(weightLb)) {
parts.push(`${weightLb.toFixed(2)} lb`);
}
if (record) {
parts.push(
`${Number(record.dimLIn)}×${Number(record.dimWIn)}×${Number(record.dimHIn)} in`,
);
} else if (o?.dimensions && typeof o.dimensions === "object") {
const d = o.dimensions as Record<string, unknown>;
const unit = d.unit === "cm" ? "cm" : "in";
parts.push(
`${d.length}×${d.width}×${d.height} ${unit}`,
);
}
if (typeof cargoType === "string") {
parts.push(CARGO_TYPE_ZH[cargoType] ?? cargoType);
}
return parts.length > 0 ? parts.join(" · ") : "—";
}
function normalizeTechnicalReason(raw: string): string {
const text = raw.trim();
if (!text) {
return "";
}
if (/browserType\.launch/i.test(text)) {
return "RPA Worker 无法启动浏览器Playwright请检查 Worker 容器资源、Chromium 依赖与进程日志";
}
if (/Executable doesn't exist|chromium_headless_shell/i.test(text)) {
return "服务器未安装 Playwright 浏览器,请在 Worker 镜像中执行浏览器安装";
}
if (/SESSION_EXPIRED|storageState|会话/i.test(text)) {
return "MotherShip 登录会话失效,需重新录制 .rpa/mothership-storage.json";
}
if (/RPA_DATA_INVALID|无法解析.*报价/i.test(text)) {
return "MotherShip 返回的报价数据无法解析为完整档位";
}
if (/QUOTE_ENTRY_UNAVAILABLE|报价入口/i.test(text)) {
return "MotherShip 报价页面无法打开或被重定向到登录页";
}
if (/ADDRESS_NOT_CONFIRMED|地址.*无法定位/i.test(text)) {
return "已选地址在 MotherShip 无法定位,需重新联想并确认";
}
if (/processing 超过|QUOTE_TIMEOUT/i.test(text)) {
return `询价超时(超过 ${Math.round(Number(process.env.QUOTE_TIMEOUT_MS ?? 420_000) / 1000)} 秒未完成)`;
}
return text;
}
const ALERT_TYPE_DEFAULT_CAUSE: Partial<Record<AlertType, string>> = {
RPA_FAILED: "询价自动化失败,未能获取 MotherShip 实时报价",
RPA_CAPTCHA: "MotherShip 触发人机验证RPA 已暂停",
STRUCT_CHANGE: "MotherShip 页面结构变更,自动化脚本需更新",
SESSION_EXPIRED: "MotherShip 会话过期,需重新登录或录制 cookies",
STALE_FALLBACK: "实时询价失败,已降级使用历史缓存报价",
PRICE_DEVIATION: "报价与历史基准偏差超过阈值",
MANUAL_FALLBACK: "已触发人工兜底询价流程",
SECURITY: "检测到安全相关异常请求",
};
/** 管理端展示用:准确中文根因,避免直接展示 JSON */
export function resolveAlertRootCause(
alertType: AlertType,
detail: unknown,
quote?: QuoteRecord | null,
): string {
const d = asRecord(detail);
const reason =
typeof d?.reason === "string"
? normalizeTechnicalReason(d.reason)
: typeof d?.error === "string"
? normalizeTechnicalReason(d.error)
: "";
if (quote?.errorMessage?.trim()) {
return normalizeTechnicalReason(quote.errorMessage.trim());
}
if (quote?.errorCode) {
return formatQuoteErrorMessage(quote.errorCode, quote.errorMessage);
}
if (reason) {
return reason;
}
return ALERT_TYPE_DEFAULT_CAUSE[alertType] ?? "系统预警,请结合报价单号追查";
}
export type AlertPresentation = {
root_cause: string;
customer_id: string | null;
pickup_customer: string;
pickup_selected: string;
delivery_customer: string;
delivery_selected: string;
cargo_summary: string;
};
export function buildAlertPresentation(
alertType: AlertType,
detail: unknown,
quote?: QuoteRecord | null,
): AlertPresentation {
const pickup = quote?.pickupJson ?? null;
const delivery = quote?.deliveryJson ?? null;
return {
root_cause: resolveAlertRootCause(alertType, detail, quote),
customer_id:
quote?.customerId ??
(typeof asRecord(detail)?.customer_id === "string"
? (asRecord(detail)!.customer_id as string)
: null),
pickup_customer: formatCustomerAddressForAdmin(pickup),
pickup_selected: formatSelectedAddressForAdmin(pickup),
delivery_customer: formatCustomerAddressForAdmin(delivery),
delivery_selected: formatSelectedAddressForAdmin(delivery),
cargo_summary: formatCargoForAdmin(null, quote ?? null),
};
}