|
|
import { QUOTE_TIMEOUT_MS } from "@/lib/constants/quote";
|
|
|
import { MS_NEEDS_DETAILS_MESSAGE } from "@/lib/constants/ms-refine-hold";
|
|
|
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";
|
|
|
import { scrubProviderBrandForCustomer } from "@/lib/frontend/scrub-provider-brand";
|
|
|
|
|
|
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 / 官网控件 testId → 中文名(须具体到控件,禁止笼统「创建货件/报价表单控件」)
|
|
|
* 顺序:更具体的规则在前。
|
|
|
*/
|
|
|
const TEST_ID_ZH: Array<[RegExp, string]> = [
|
|
|
[
|
|
|
/address-book-accessorials-option-appointment/i,
|
|
|
"预约送货(Appointment)附加服务选项",
|
|
|
],
|
|
|
[/address-book-accessorials-option-/i, "地址簿附加服务选项"],
|
|
|
[/address-book-accessorials-trigger/i, "附加服务下拉入口"],
|
|
|
[/auth-email-input|auth-password-input|auth-log-in-button/i, "登录表单"],
|
|
|
[/quote-create-pickup-input-search/i, "提货地址搜索框"],
|
|
|
[/quote-create-delivery-input-search/i, "派送地址搜索框"],
|
|
|
[/ship-create-continue-button/i, "创建货件页「继续」按钮"],
|
|
|
[/cargo-type-dropdown-input/i, "货物类型下拉框"],
|
|
|
[/rate-card/i, "承运商报价卡片"],
|
|
|
[/quote-details-rate-option-/i, "报价选项卡片"],
|
|
|
[/quote-create-/i, "创建货件页表单字段"],
|
|
|
[/ship-create-/i, "创建货件页操作按钮"],
|
|
|
];
|
|
|
|
|
|
/** 按控件给出可核对的超时原因(禁止臆测「附加服务未展开」等无关套话) */
|
|
|
const TARGET_TIMEOUT_HINT: Record<string, { wait: string; act: string }> = {
|
|
|
提货地址搜索框: {
|
|
|
wait: "创建货件页未渲染出提货地址框,或登录会话未进入创建页",
|
|
|
act: "提货地址框被 Inbox/弹窗遮挡,或页面仍在加载",
|
|
|
},
|
|
|
派送地址搜索框: {
|
|
|
wait: "创建货件页未渲染出派送地址框,或登录会话未进入创建页",
|
|
|
act: "派送地址框被 Inbox/弹窗遮挡,或页面仍在加载",
|
|
|
},
|
|
|
"创建货件页「继续」按钮": {
|
|
|
wait: "创建货件页「继续」按钮未出现(地址/货物可能未确认,或仍停在登录/首页)",
|
|
|
act: "「继续」按钮仍禁用或被遮罩挡住,无法提交一级询价",
|
|
|
},
|
|
|
承运商报价卡片: {
|
|
|
wait: "官网未返回可用报价卡片(仍在算价、线路无运力、或已要求补填二级详情)",
|
|
|
act: "报价卡片存在但无法点选(可能被 Inbox/遮罩挡住)",
|
|
|
},
|
|
|
货物类型下拉框: {
|
|
|
wait: "货物类型下拉未出现(页面未滚到货物区,或表单结构变化)",
|
|
|
act: "货物类型下拉被遮挡或暂时不可点",
|
|
|
},
|
|
|
附加服务下拉入口: {
|
|
|
wait: "附加服务入口未出现(地址尚未确认)",
|
|
|
act: "附加服务入口被遮挡或暂时不可点",
|
|
|
},
|
|
|
"预约送货(Appointment)附加服务选项": {
|
|
|
wait: "预约送货选项未出现在附加服务列表中(当前地址可能不支持)",
|
|
|
act: "预约送货选项不可点或未展开",
|
|
|
},
|
|
|
地址簿附加服务选项: {
|
|
|
wait: "所需附加服务选项未出现在列表中",
|
|
|
act: "附加服务选项不可点或未展开",
|
|
|
},
|
|
|
登录表单: {
|
|
|
wait: "未进入官网登录页或登录表单未加载",
|
|
|
act: "登录表单填写/提交失败",
|
|
|
},
|
|
|
};
|
|
|
|
|
|
/** 常见英文业务/官网文案 → 中文 */
|
|
|
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,
|
|
|
"请检查并填写所有必填项后再继续",
|
|
|
],
|
|
|
[
|
|
|
/MS_NEEDS_DETAILS|一级询价未能直接出价|请继续填写二级详情/i,
|
|
|
MS_NEEDS_DETAILS_MESSAGE,
|
|
|
],
|
|
|
[
|
|
|
/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;
|
|
|
}
|
|
|
|
|
|
function extractTimeoutSeconds(text: string): number | null {
|
|
|
const timeoutMatch = text.match(/Timeout\s+(\d+)\s*ms/i);
|
|
|
if (!timeoutMatch) return null;
|
|
|
const ms = Number(timeoutMatch[1]);
|
|
|
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
|
return Math.max(1, Math.round(ms / 1000));
|
|
|
}
|
|
|
|
|
|
/** click/fill 与 waitFor(visible) 文案不同 */
|
|
|
function isActionTimeout(text: string): boolean {
|
|
|
return /locator\.(?:click|fill|type|press)|waiting for (?:locator\.)?(?:click|fill)/i.test(
|
|
|
text,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function formatControlTimeoutMessage(
|
|
|
target: string,
|
|
|
text: string,
|
|
|
): string {
|
|
|
const sec = extractTimeoutSeconds(text);
|
|
|
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
|
|
|
const action = isActionTimeout(text);
|
|
|
const verb = action ? "操作" : "等到";
|
|
|
const hintEntry = TARGET_TIMEOUT_HINT[target];
|
|
|
const reason = hintEntry
|
|
|
? action
|
|
|
? hintEntry.act
|
|
|
: hintEntry.wait
|
|
|
: action
|
|
|
? "目标控件被遮挡、仍禁用,或页面未加载完成"
|
|
|
: "目标控件未出现或未变为可见";
|
|
|
return `自动化操作超时${waitHint}:未能${verb}「${target}」。原因:${reason}。请重试;若反复失败请联系管理员查看 Worker 日志。`;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 纠正历史入库的笼统超时文案(已丢失原始 testId,只能按时间特征收紧表述)
|
|
|
*/
|
|
|
export function rewriteLegacyVagueTimeoutMessage(raw: string): string | null {
|
|
|
const text = raw.trim();
|
|
|
if (!/自动化操作超时/i.test(text)) return null;
|
|
|
if (
|
|
|
/未能等到「创建货件\/报价表单控件」|未能等到「创建货件页表单字段」|可能原因:官网页面结构变更、附加服务选项未展开/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
const secMatch = text.match(/等待约\s*(\d+)\s*秒/);
|
|
|
const sec = secMatch ? Number(secMatch[1]) : null;
|
|
|
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
|
|
|
if (sec != null && sec <= 8) {
|
|
|
return `自动化操作超时${waitHint}:未能及时操作创建货件页控件(多为提货/派送地址搜索框或「继续」按钮被 Inbox/弹窗遮挡,或页面未加载完)。请重试;若反复失败请联系管理员查看 Worker 日志。`;
|
|
|
}
|
|
|
return `自动化操作超时${waitHint}:创建货件页关键控件未按时出现(可能未进入创建页、登录会话异常,或官网仍在加载)。请重试;若反复失败请联系管理员查看 Worker 日志。`;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 可提货日/日历失败:去掉 want=/sample= 英文诊断,统一中文 */
|
|
|
export function sanitizeCalendarPickErrorMessage(raw: string): string | null {
|
|
|
const text = raw.trim();
|
|
|
if (
|
|
|
!/登录态日历未点到|未能选择可提货日|未能切换到可提货月|可提货日无效/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
return null;
|
|
|
}
|
|
|
if (/可提货日无效/i.test(text)) {
|
|
|
const bad = text.match(/20\d{2}-\d{2}-\d{2}/);
|
|
|
return bad ? `可提货日无效:${bad[0]}` : "可提货日无效,请更换日期后重试";
|
|
|
}
|
|
|
if (/未能切换到可提货月/i.test(text)) {
|
|
|
const ym = text.match(/(\d{4})年(\d{1,2})月/);
|
|
|
return ym
|
|
|
? `未能切换到可提货月 ${ym[1]}年${ym[2]}月,请稍后重试或更换日期`
|
|
|
: "未能切换到可提货月,请稍后重试或更换日期";
|
|
|
}
|
|
|
const dateMatch = text.match(/20\d{2}-\d{2}-\d{2}/);
|
|
|
return dateMatch
|
|
|
? `未能选择可提货日 ${dateMatch[0]},请稍后重试或更换日期`
|
|
|
: "未能选择可提货日,请稍后重试或更换日期";
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Playwright / 堆栈 / 已知错误码 → 中文根因(客户与管理端共用)。
|
|
|
* 无法识别时返回空串(由调用方按 error_code 回退)。
|
|
|
*/
|
|
|
export function normalizeTechnicalReason(raw: string): string {
|
|
|
const text = raw.trim();
|
|
|
if (!text) {
|
|
|
return "";
|
|
|
}
|
|
|
const legacyTimeout = rewriteLegacyVagueTimeoutMessage(text);
|
|
|
if (legacyTimeout) {
|
|
|
return legacyTimeout;
|
|
|
}
|
|
|
const calendarMsg = sanitizeCalendarPickErrorMessage(text);
|
|
|
if (calendarMsg) {
|
|
|
return calendarMsg;
|
|
|
}
|
|
|
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 "暂时无法获取报价,请稍后重试";
|
|
|
}
|
|
|
if (
|
|
|
/PROVIDER_LOGIN_FAILED|FLOCK_LOGIN_FAILED|仍停在登录页|账号或密码错误|账密可能错误/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
return "承运商官网登录失败:账号或密码不正确,或修改后未在本系统即时保存更新";
|
|
|
}
|
|
|
if (/SESSION_EXPIRED|storageState|会话已过期|登录会话失效/i.test(text)) {
|
|
|
return "报价会话已失效,请重新询价";
|
|
|
}
|
|
|
if (/缺少标准档报价/i.test(text)) {
|
|
|
return "报价结果缺少标准档,无法展示完整运价";
|
|
|
}
|
|
|
if (/官网展示档位为空|报价档位数不足/i.test(text)) {
|
|
|
return "未能解析出可用承运商报价档位";
|
|
|
}
|
|
|
if (/RPA_DATA_INVALID|无法解析.*报价|未能解析出承运商报价/i.test(text)) {
|
|
|
return "返回的报价数据无法解析为完整档位";
|
|
|
}
|
|
|
if (/ADDRESS_NOT_CONFIRMED|地址.*无法定位|地址未确认/i.test(text)) {
|
|
|
return "该地址无法确认,请重新选择后询价";
|
|
|
}
|
|
|
if (/二级填写已超时|确认窗口已超时|超过 5 分钟/i.test(text)) {
|
|
|
return MS_NEEDS_DETAILS_MESSAGE;
|
|
|
}
|
|
|
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)
|
|
|
) {
|
|
|
if (
|
|
|
/First name|Last name|contact-first|contact-last|element is not enabled/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
return "自动化操作超时:官网 Details 联系人字段未解锁(禁用态)。原因:Inbox 遮挡、附加服务推荐未点 Skip、或需先点 Save & update quote。";
|
|
|
}
|
|
|
if (
|
|
|
/ready.?pickup|Ready for pick|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec.*20\d{2}|Chevron/i.test(
|
|
|
text,
|
|
|
)
|
|
|
) {
|
|
|
const sec = extractTimeoutSeconds(text);
|
|
|
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
|
|
|
return `自动化操作超时${waitHint}:未能打开或选择「可提货日」。原因:Inbox 消息遮挡、日期控件改版,或网络过慢。`;
|
|
|
}
|
|
|
const target = describeTestIdTarget(text);
|
|
|
if (target) {
|
|
|
return formatControlTimeoutMessage(target, text);
|
|
|
}
|
|
|
const sec = extractTimeoutSeconds(text);
|
|
|
const waitHint = sec != null ? `(等待约 ${sec} 秒)` : "";
|
|
|
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() ?? "";
|
|
|
let out = fallback;
|
|
|
if (!raw) {
|
|
|
out = fallback;
|
|
|
} else if (
|
|
|
code === "PROVIDER_LOGIN_FAILED" ||
|
|
|
isProviderLoginFailureMessage(raw)
|
|
|
) {
|
|
|
out = PROVIDER_LOGIN_FAILED_USER_MESSAGE;
|
|
|
} else {
|
|
|
const stripped = stripErrorCodePrefix(raw);
|
|
|
const legacyTimeout = rewriteLegacyVagueTimeoutMessage(stripped || raw);
|
|
|
if (legacyTimeout) {
|
|
|
out = legacyTimeout;
|
|
|
} else {
|
|
|
const calendarClean =
|
|
|
sanitizeCalendarPickErrorMessage(stripped) ||
|
|
|
sanitizeCalendarPickErrorMessage(raw);
|
|
|
if (calendarClean) {
|
|
|
out = calendarClean;
|
|
|
} else if (isChineseUserFacingMessage(stripped)) {
|
|
|
out = stripped;
|
|
|
} else if (isChineseUserFacingMessage(raw)) {
|
|
|
out = raw;
|
|
|
} else {
|
|
|
const translated = translateKnownEnglishPhrases(stripped || raw);
|
|
|
if (translated !== (stripped || raw) && hasCjk(translated)) {
|
|
|
out = translated;
|
|
|
} else {
|
|
|
const fromTech = normalizeTechnicalReason(stripped || raw);
|
|
|
out = fromTech || fallback;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return scrubProviderBrandForCustomer(out);
|
|
|
}
|
|
|
|
|
|
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)}…`;
|
|
|
}
|