|
|
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),
|
|
|
};
|
|
|
}
|