|
|
import { QUOTE_TIMEOUT_MS } from "@/lib/constants/quote";
|
|
|
import { RpaDataInvalidError } from "@/modules/quote/types";
|
|
|
import type { RpaErrorCode } from "@/modules/rpa/errors";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import {
|
|
|
isProviderLoginFailureMessage,
|
|
|
PROVIDER_LOGIN_FAILED_USER_MESSAGE,
|
|
|
} from "@/modules/rpa/provider-login-message";
|
|
|
|
|
|
const QUOTE_TIMEOUT_SEC = Math.round(QUOTE_TIMEOUT_MS / 1_000);
|
|
|
|
|
|
/** 对外展示的询价失败 error_code(domain-rules §10 + RPA 细分) */
|
|
|
export type QuoteFailureCode =
|
|
|
| "QUOTE_UNAVAILABLE"
|
|
|
| "QUOTE_TIMEOUT"
|
|
|
| "QUOTE_ENTRY_UNAVAILABLE"
|
|
|
| "ADDRESS_SUGGESTION_NOT_FOUND"
|
|
|
| "CARRIER_NO_CAPACITY"
|
|
|
| "ADDRESS_NOT_SUPPORTED"
|
|
|
| "RPA_CAPTCHA"
|
|
|
| "SESSION_EXPIRED"
|
|
|
| "PROVIDER_LOGIN_FAILED"
|
|
|
| "RPA_DATA_INVALID"
|
|
|
| "PAGE_LOAD_TIMEOUT";
|
|
|
|
|
|
/** 面向中文客户的默认文案(禁止英文技术栈细节) */
|
|
|
const DEFAULT_MESSAGES: Record<QuoteFailureCode, string> = {
|
|
|
QUOTE_UNAVAILABLE: "暂时无法获取报价,请稍后重试",
|
|
|
QUOTE_TIMEOUT: `查询超时(超过 ${QUOTE_TIMEOUT_SEC} 秒),请重试`,
|
|
|
QUOTE_ENTRY_UNAVAILABLE: "报价入口暂时无法打开,请稍后重试或联系管理员",
|
|
|
ADDRESS_SUGGESTION_NOT_FOUND:
|
|
|
"无法匹配您已确认的地址,请重新选择提货/派送地址",
|
|
|
CARRIER_NO_CAPACITY: "该线路暂无可用运力,请调整货物或地址后重试",
|
|
|
ADDRESS_NOT_SUPPORTED: "该地址不在承运商服务范围内",
|
|
|
RPA_CAPTCHA: "承运商触发验证码,询价已暂停,请稍后重试或联系管理员",
|
|
|
SESSION_EXPIRED: "承运商登录会话已过期,请确认账密后重试",
|
|
|
PROVIDER_LOGIN_FAILED: PROVIDER_LOGIN_FAILED_USER_MESSAGE,
|
|
|
RPA_DATA_INVALID: "未能解析完整报价结果,请稍后重试",
|
|
|
PAGE_LOAD_TIMEOUT: "承运商页面加载超时,请稍后重试",
|
|
|
};
|
|
|
|
|
|
const RPA_CODE_MAP: Partial<Record<RpaErrorCode, QuoteFailureCode>> = {
|
|
|
QUOTE_ENTRY_UNAVAILABLE: "QUOTE_ENTRY_UNAVAILABLE",
|
|
|
ADDRESS_SUGGESTION_NOT_FOUND: "ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
CARRIER_NO_CAPACITY: "CARRIER_NO_CAPACITY",
|
|
|
ADDRESS_NOT_SUPPORTED: "ADDRESS_NOT_SUPPORTED",
|
|
|
RPA_CAPTCHA: "RPA_CAPTCHA",
|
|
|
SESSION_EXPIRED: "SESSION_EXPIRED",
|
|
|
PROVIDER_LOGIN_FAILED: "PROVIDER_LOGIN_FAILED",
|
|
|
RPA_DATA_INVALID: "RPA_DATA_INVALID",
|
|
|
PAGE_LOAD_TIMEOUT: "PAGE_LOAD_TIMEOUT",
|
|
|
STRUCT_CHANGE: "QUOTE_ENTRY_UNAVAILABLE",
|
|
|
};
|
|
|
|
|
|
const CJK_PATTERN = /[\u4e00-\u9fff]/;
|
|
|
/** 仅匹配明确的 Playwright/堆栈噪声;勿把「含少量英文的中文业务句」整段判死刑 */
|
|
|
const TECHNICAL_NOISE_PATTERN =
|
|
|
/locator\.|page\.goto|Timeout \d+\s*ms|waitFor|getByTestId|Call log|ERR_|ECONN|Prisma|webpack|playwright|browserType|net::|stack|at Object\.|node_modules/i;
|
|
|
|
|
|
const ERROR_CODE_PREFIX =
|
|
|
/^(?:RPA_[A-Z0-9_]+|FLOCK_[A-Z0-9_]+|QUOTE_[A-Z0-9_]+|STRUCT_CHANGE|SESSION_EXPIRED|PAGE_LOAD_TIMEOUT|PROVIDER_LOGIN_FAILED|MOTHERSHIP_[A-Z0-9_]+|CARRIER_[A-Z0-9_]+|ADDRESS_[A-Z0-9_]+|CARGO_[A-Z0-9_]+)\s*[::]\s*/i;
|
|
|
|
|
|
/** 常见 Playwright / MotherShip 控件 testId → 中文含义 */
|
|
|
const TEST_ID_ZH: Array<[RegExp, string]> = [
|
|
|
[
|
|
|
/address-book-accessorials-option-appointment/i,
|
|
|
"预约送货(Appointment)附加服务选项",
|
|
|
],
|
|
|
[/address-book-accessorials-option-/i, "地址簿附加服务选项"],
|
|
|
[/auth-email-input|auth-password-input|auth-log-in-button/i, "登录表单"],
|
|
|
[/quote-create-pickup-input-search/i, "提货地址搜索框"],
|
|
|
[/quote-create-delivery-input-search/i, "派送地址搜索框"],
|
|
|
[/quote-create-|rate-card|ship-create/i, "创建货件/报价表单控件"],
|
|
|
];
|
|
|
|
|
|
/** 常见英文业务/官网文案 → 中文 */
|
|
|
const EN_PHRASE_TO_ZH: Array<[RegExp, string]> = [
|
|
|
[
|
|
|
/no rates found[\s\S]*please review all required fields to proceed/i,
|
|
|
"未找到可用报价。请检查并填写所有必填项后再继续",
|
|
|
],
|
|
|
[/no rates found/i, "未找到可用报价"],
|
|
|
[
|
|
|
/please review all required fields to proceed/i,
|
|
|
"请检查并填写所有必填项后再继续",
|
|
|
],
|
|
|
[
|
|
|
/temporarily unable to (?:obtain|get|fetch) quotes?/i,
|
|
|
"暂时无法获取报价,请检查地址、货物或附加服务后重试",
|
|
|
],
|
|
|
[/unable to (?:obtain|get|fetch) quotes?/i, "暂时无法获取报价"],
|
|
|
[/no carriers available/i, "该线路暂无可用运力"],
|
|
|
[/no capacity/i, "该线路暂无可用运力"],
|
|
|
[/unable to find a carrier/i, "未找到可用承运商"],
|
|
|
[/address not supported/i, "该地址不在服务范围内"],
|
|
|
[/zip code is not supported/i, "该邮编不在服务范围内"],
|
|
|
[/we don'?t service this area/i, "该区域暂不提供服务"],
|
|
|
[/unsupported accessorial/i, "不支持该附加服务"],
|
|
|
[
|
|
|
/our carrier partners? do not provide[^\n.]*/i,
|
|
|
"承运合作伙伴不提供此项服务",
|
|
|
],
|
|
|
[
|
|
|
/carrier partners? (?:don'?t|do not) provide[^\n.]*/i,
|
|
|
"承运合作伙伴不提供此项服务",
|
|
|
],
|
|
|
[
|
|
|
/(?:pickup|pick-up)[^\n.]{0,40}residential|residential[^\n.]{0,40}(?:pickup|pick-up)/i,
|
|
|
"不支持住宅地址上门取件",
|
|
|
],
|
|
|
[
|
|
|
/incorrect (?:email|password)|invalid (?:email|password|credentials)/i,
|
|
|
"账号或密码不正确",
|
|
|
],
|
|
|
[/wrong password/i, "密码不正确"],
|
|
|
[/captcha|challenge/i, "触发人机验证,请稍后重试"],
|
|
|
];
|
|
|
|
|
|
function hasCjk(text: string): boolean {
|
|
|
return CJK_PATTERN.test(text);
|
|
|
}
|
|
|
|
|
|
function countCjk(text: string): number {
|
|
|
return (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
|
|
|
}
|
|
|
|
|
|
/** 去掉 RPA_DATA_INVALID: 一类前缀,便于保留后面的中文原因 */
|
|
|
export function stripErrorCodePrefix(message: string): string {
|
|
|
return message.replace(ERROR_CODE_PREFIX, "").trim();
|
|
|
}
|
|
|
|
|
|
function describeTestIdTarget(text: string): string | null {
|
|
|
for (const [pattern, label] of TEST_ID_ZH) {
|
|
|
if (pattern.test(text)) {
|
|
|
return label;
|
|
|
}
|
|
|
}
|
|
|
const m = text.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/i);
|
|
|
if (m?.[1]) {
|
|
|
return `页面控件「${m[1]}」`;
|
|
|
}
|
|
|
if (/getBy(?:Text|Role|Label|Placeholder)\([^)]+\)/i.test(text)) {
|
|
|
return "目标页面元素";
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Playwright / 堆栈 / 已知错误码 → 中文根因(客户与管理端共用)。
|
|
|
* 无法识别时返回空串(由调用方按 error_code 回退)。
|
|
|
*/
|
|
|
export function normalizeTechnicalReason(raw: string): string {
|
|
|
const text = raw.trim();
|
|
|
if (!text) {
|
|
|
return "";
|
|
|
}
|
|
|
if (/browserType\.launch/i.test(text)) {
|
|
|
return "RPA Worker 无法启动浏览器,请检查 Worker 资源、Chromium 依赖与进程日志";
|
|
|
}
|
|
|
if (/Executable doesn't exist|chromium_headless_shell/i.test(text)) {
|
|
|
return "服务器未安装 Playwright 浏览器,请在 Worker 环境中安装浏览器后重试";
|
|
|
}
|
|
|
if (/QUOTE_ENTRY_UNAVAILABLE|报价入口不可用|报价页面无法打开/i.test(text)) {
|
|
|
return "MotherShip 报价页面无法打开或被重定向到登录页";
|
|
|
}
|
|
|
if (
|
|
|
/PROVIDER_LOGIN_FAILED|FLOCK_LOGIN_FAILED|仍停在登录页|账号或密码错误|账密可能错误/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
return "承运商官网登录失败:账号或密码不正确,或修改后未在本系统即时保存更新";
|
|
|
}
|
|
|
if (/SESSION_EXPIRED|storageState|会话已过期|登录会话失效/i.test(text)) {
|
|
|
return "MotherShip 登录会话失效,需重新登录或更新登录态文件";
|
|
|
}
|
|
|
if (/缺少标准档报价/i.test(text)) {
|
|
|
return "报价结果缺少标准档,无法展示完整运价";
|
|
|
}
|
|
|
if (/官网展示档位为空|报价档位数不足/i.test(text)) {
|
|
|
return "未能解析出可用承运商报价档位";
|
|
|
}
|
|
|
if (/RPA_DATA_INVALID|无法解析.*报价|未能解析出承运商报价/i.test(text)) {
|
|
|
return "MotherShip 返回的报价数据无法解析为完整档位";
|
|
|
}
|
|
|
if (/ADDRESS_NOT_CONFIRMED|地址.*无法定位|地址未确认/i.test(text)) {
|
|
|
return "已选地址在 MotherShip 无法定位或未确认,需重新联想并确认";
|
|
|
}
|
|
|
if (/processing 超过|QUOTE_TIMEOUT/i.test(text)) {
|
|
|
return `询价超时(超过 ${QUOTE_TIMEOUT_SEC} 秒未完成)`;
|
|
|
}
|
|
|
if (
|
|
|
/locator\.waitFor|waiting for getBy|Timeout \d+\s*ms exceeded/i.test(text)
|
|
|
) {
|
|
|
const target = describeTestIdTarget(text);
|
|
|
const timeoutMatch = text.match(/Timeout\s+(\d+)\s*ms/i);
|
|
|
const sec = timeoutMatch
|
|
|
? Math.round(Number(timeoutMatch[1]) / 1000)
|
|
|
: null;
|
|
|
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
|
|
|
if (target) {
|
|
|
return `自动化操作超时${waitHint}:未能等到「${target}」出现或变为可见。可能原因:官网页面结构变更、附加服务选项未展开、网络过慢或登录态异常。`;
|
|
|
}
|
|
|
return `自动化操作超时${waitHint}:未能等到目标页面元素出现或变为可见。可能原因:官网页面结构变更、网络过慢或登录态异常。`;
|
|
|
}
|
|
|
if (/page\.goto:|net::ERR_/i.test(text)) {
|
|
|
return "打开承运商官网失败(网络连接异常或被重置),请检查 Worker 出口网络后重试";
|
|
|
}
|
|
|
if (/Target (?:page|context|browser) has been closed/i.test(text)) {
|
|
|
return "浏览器会话已关闭,询价中断,请重试";
|
|
|
}
|
|
|
// 已是可读中文业务句:原样返回(管理端与客户展示共用)
|
|
|
if (isChineseUserFacingMessage(text)) {
|
|
|
return stripErrorCodePrefix(text);
|
|
|
}
|
|
|
return "";
|
|
|
}
|
|
|
|
|
|
/** 是否适合直接给中文客户看(含汉字且非 Playwright 堆栈噪声) */
|
|
|
export function isChineseUserFacingMessage(message: string): boolean {
|
|
|
const trimmed = stripErrorCodePrefix(message.trim());
|
|
|
if (!trimmed) return false;
|
|
|
if (!hasCjk(trimmed)) return false;
|
|
|
const cjk = countCjk(trimmed);
|
|
|
// 业务中文句常夹杂 rate-card / Continue:汉字够多且无堆栈特征则放行
|
|
|
if (cjk >= 4 && !TECHNICAL_NOISE_PATTERN.test(trimmed)) {
|
|
|
return true;
|
|
|
}
|
|
|
if (TECHNICAL_NOISE_PATTERN.test(trimmed)) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 将已知英文短语替换为中文;无法识别则返回原串 */
|
|
|
export function translateKnownEnglishPhrases(message: string): string {
|
|
|
const trimmed = message.trim();
|
|
|
if (!trimmed) return trimmed;
|
|
|
for (const [pattern, zh] of EN_PHRASE_TO_ZH) {
|
|
|
if (pattern.test(trimmed)) {
|
|
|
return zh;
|
|
|
}
|
|
|
}
|
|
|
return trimmed;
|
|
|
}
|
|
|
|
|
|
function defaultMessageFor(code: QuoteFailureCode): string {
|
|
|
return DEFAULT_MESSAGES[code] ?? DEFAULT_MESSAGES.QUOTE_UNAVAILABLE;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 将任意错误原文转为中文用户可见文案。
|
|
|
* 规则:合格中文 → 保留;已知英文/Playwright → 译中文;否则按 error_code 回退(禁止吞掉可译原因)。
|
|
|
*/
|
|
|
export function toChineseUserFacingMessage(
|
|
|
message: string | null | undefined,
|
|
|
errorCode?: string | null,
|
|
|
): string {
|
|
|
const code = (errorCode ?? "QUOTE_UNAVAILABLE") as QuoteFailureCode;
|
|
|
const fallback = defaultMessageFor(
|
|
|
code in DEFAULT_MESSAGES ? code : "QUOTE_UNAVAILABLE",
|
|
|
);
|
|
|
|
|
|
const raw = message?.trim() ?? "";
|
|
|
if (!raw) {
|
|
|
return fallback;
|
|
|
}
|
|
|
if (
|
|
|
code === "PROVIDER_LOGIN_FAILED" ||
|
|
|
isProviderLoginFailureMessage(raw)
|
|
|
) {
|
|
|
return PROVIDER_LOGIN_FAILED_USER_MESSAGE;
|
|
|
}
|
|
|
|
|
|
const stripped = stripErrorCodePrefix(raw);
|
|
|
if (isChineseUserFacingMessage(stripped)) {
|
|
|
return stripped;
|
|
|
}
|
|
|
if (isChineseUserFacingMessage(raw)) {
|
|
|
return raw;
|
|
|
}
|
|
|
|
|
|
const translated = translateKnownEnglishPhrases(stripped || raw);
|
|
|
if (translated !== (stripped || raw) && hasCjk(translated)) {
|
|
|
return translated;
|
|
|
}
|
|
|
|
|
|
const fromTech = normalizeTechnicalReason(stripped || raw);
|
|
|
if (fromTech) {
|
|
|
return fromTech;
|
|
|
}
|
|
|
|
|
|
return fallback;
|
|
|
}
|
|
|
|
|
|
export function resolveQuoteFailureCode(error: unknown): QuoteFailureCode {
|
|
|
if (error instanceof RpaError) {
|
|
|
if (
|
|
|
error.code === "PROVIDER_LOGIN_FAILED" ||
|
|
|
isProviderLoginFailureMessage(error.message)
|
|
|
) {
|
|
|
return "PROVIDER_LOGIN_FAILED";
|
|
|
}
|
|
|
return RPA_CODE_MAP[error.code] ?? "QUOTE_UNAVAILABLE";
|
|
|
}
|
|
|
if (error instanceof RpaDataInvalidError) {
|
|
|
return "RPA_DATA_INVALID";
|
|
|
}
|
|
|
if (error instanceof Error && isProviderLoginFailureMessage(error.message)) {
|
|
|
return "PROVIDER_LOGIN_FAILED";
|
|
|
}
|
|
|
if (
|
|
|
error instanceof Error &&
|
|
|
/Timeout \d+\s*ms|locator\.|page\.goto|net::ERR_/i.test(error.message)
|
|
|
) {
|
|
|
return "PAGE_LOAD_TIMEOUT";
|
|
|
}
|
|
|
return "QUOTE_UNAVAILABLE";
|
|
|
}
|
|
|
|
|
|
export function resolveQuoteFailureMessage(
|
|
|
error: unknown,
|
|
|
errorCode: QuoteFailureCode,
|
|
|
): string {
|
|
|
if (errorCode === "PROVIDER_LOGIN_FAILED") {
|
|
|
return PROVIDER_LOGIN_FAILED_USER_MESSAGE;
|
|
|
}
|
|
|
const raw =
|
|
|
error instanceof Error
|
|
|
? error.message.trim()
|
|
|
: typeof error === "string"
|
|
|
? error.trim()
|
|
|
: "";
|
|
|
return toChineseUserFacingMessage(raw, errorCode);
|
|
|
}
|
|
|
|
|
|
/** GET /quotes/{id} 展示文案:强制中文客户可读 */
|
|
|
export function formatQuoteErrorMessage(
|
|
|
errorCode: string | null | undefined,
|
|
|
storedMessage?: string | null,
|
|
|
): string {
|
|
|
return toChineseUserFacingMessage(storedMessage, errorCode);
|
|
|
}
|
|
|
|
|
|
/** quote_record.error_message VARCHAR(512) */
|
|
|
export function truncateQuoteErrorMessage(
|
|
|
message: string,
|
|
|
maxLen = 512,
|
|
|
): string {
|
|
|
const trimmed = message.trim();
|
|
|
if (trimmed.length <= maxLen) {
|
|
|
return trimmed;
|
|
|
}
|
|
|
return `${trimmed.slice(0, maxLen - 1)}…`;
|
|
|
}
|