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.

523 lines
20 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 { 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)}…`;
}