|
|
import { Prisma } from "@prisma/client";
|
|
|
import { writeAlert } from "@/modules/alert/service";
|
|
|
import { getL3 } from "@/modules/cache/redis-cache";
|
|
|
import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { getConfidenceScore } from "@/modules/quote/confidence";
|
|
|
import {
|
|
|
applyMarkupToQuotes,
|
|
|
getMarkupRule,
|
|
|
type RawQuoteTier,
|
|
|
} from "@/modules/pricing/engine";
|
|
|
import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness";
|
|
|
import { recordPostDone, safeRecord } from "@/modules/metrics/collector";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { isEntryErrorCode } from "@/modules/rpa/error-mapper";
|
|
|
import {
|
|
|
resolveQuoteFailureCode,
|
|
|
resolveQuoteFailureMessage,
|
|
|
truncateQuoteErrorMessage,
|
|
|
type QuoteFailureCode,
|
|
|
} from "@/modules/quote/quote-error-messages";
|
|
|
import type { L2CachePayload } from "@/modules/quote/types";
|
|
|
import { recordQuoteQueryOutcome } from "@/modules/quote/query-log";
|
|
|
|
|
|
export type FallbackResult =
|
|
|
| {
|
|
|
status: "done";
|
|
|
sourceType: "stale";
|
|
|
isRealtime: false;
|
|
|
quotes: ReturnType<typeof applyMarkupToQuotes>;
|
|
|
}
|
|
|
| {
|
|
|
status: "failed";
|
|
|
errorCode: QuoteFailureCode;
|
|
|
errorMessage: string;
|
|
|
};
|
|
|
|
|
|
/** 入口类错误不重试,直接降级(task-129) */
|
|
|
export function shouldConsumeRpaRetry(error: unknown): boolean {
|
|
|
if (error instanceof RpaError && isEntryErrorCode(error.code)) {
|
|
|
return false;
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* RPA 失败 → L3 降级完整编排(task-092)
|
|
|
* 不抛异常阻断主流程
|
|
|
*/
|
|
|
export async function handleRpaFailure(
|
|
|
quoteId: string,
|
|
|
cargoHash: string,
|
|
|
customerId: string,
|
|
|
error: unknown,
|
|
|
options?: { skipStaleFallback?: boolean },
|
|
|
): Promise<FallbackResult> {
|
|
|
const l3 = options?.skipStaleFallback
|
|
|
? null
|
|
|
: await getL3<L2CachePayload>(cargoHash);
|
|
|
const errorMessage =
|
|
|
error instanceof Error ? error.message : String(error ?? "unknown");
|
|
|
const failureCode = resolveQuoteFailureCode(error);
|
|
|
const userMessage = truncateQuoteErrorMessage(
|
|
|
resolveQuoteFailureMessage(error, failureCode),
|
|
|
);
|
|
|
|
|
|
if (l3?.quotes?.length) {
|
|
|
let uiQuotes: RawQuoteTier[];
|
|
|
try {
|
|
|
uiQuotes = prepareMotherShipStorageQuotes(l3.quotes as RawQuoteTier[]);
|
|
|
} catch {
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId },
|
|
|
data: {
|
|
|
status: "failed",
|
|
|
errorCode: failureCode,
|
|
|
errorMessage: userMessage,
|
|
|
sourceType: null,
|
|
|
isRealtime: false,
|
|
|
},
|
|
|
});
|
|
|
await writeAlert("RPA_FAILED", {
|
|
|
quoteId,
|
|
|
cargoHash,
|
|
|
detail: { reason: "L3 数据不完整", error: errorMessage },
|
|
|
});
|
|
|
await recordQuoteQueryOutcome(quoteId, "failed", {
|
|
|
failureReason: userMessage,
|
|
|
errorCode: "QUOTE_UNAVAILABLE",
|
|
|
});
|
|
|
return {
|
|
|
status: "failed",
|
|
|
errorCode: "QUOTE_UNAVAILABLE",
|
|
|
errorMessage: userMessage,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const markupRule = await getMarkupRule(customerId);
|
|
|
const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
|
|
|
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
|
|
|
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId },
|
|
|
data: {
|
|
|
status: "done",
|
|
|
sourceType: "stale",
|
|
|
isRealtime: false,
|
|
|
confidenceScore: getConfidenceScore("stale"),
|
|
|
markupPercent:
|
|
|
markupRule.type === "percent" ? markupRule.percent : 0,
|
|
|
quotesJson: markedQuotes as unknown as Prisma.InputJsonValue,
|
|
|
validUntil,
|
|
|
errorCode: null,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
await writeAlert("STALE_FALLBACK", {
|
|
|
quoteId,
|
|
|
cargoHash,
|
|
|
detail: { reason: errorMessage, customer_id: customerId },
|
|
|
});
|
|
|
|
|
|
await recordQuoteQueryOutcome(quoteId, "stale", {
|
|
|
failureReason: userMessage,
|
|
|
sourceType: "stale",
|
|
|
tierCount: markedQuotes.length,
|
|
|
});
|
|
|
|
|
|
safeRecord(() =>
|
|
|
recordPostDone({
|
|
|
quoteId,
|
|
|
sourceType: "stale",
|
|
|
isRealtime: false,
|
|
|
}),
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
status: "done",
|
|
|
sourceType: "stale",
|
|
|
isRealtime: false,
|
|
|
quotes: markedQuotes,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId },
|
|
|
data: {
|
|
|
status: "failed",
|
|
|
errorCode: failureCode,
|
|
|
errorMessage: userMessage,
|
|
|
isRealtime: false,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
await writeAlert("RPA_FAILED", {
|
|
|
quoteId,
|
|
|
cargoHash,
|
|
|
detail: { reason: errorMessage, customer_id: customerId },
|
|
|
});
|
|
|
|
|
|
await recordQuoteQueryOutcome(quoteId, "failed", {
|
|
|
failureReason: userMessage,
|
|
|
errorCode: failureCode,
|
|
|
});
|
|
|
|
|
|
return { status: "failed", errorCode: failureCode, errorMessage: userMessage };
|
|
|
}
|