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.

159 lines
3.9 KiB

import { RpaError } from "@/modules/rpa/errors";
import {
resolveGuaranteedLowestTier,
} from "@/workers/rpa/quote-capture/axel-contract";
import {
normalizeQuote,
type CanonicalQuote,
} from "@/workers/rpa/quote-capture/quote-normalize";
export type QuoteContractRates = Record<string, unknown>;
export type QuoteContractViolation = {
path: string;
reason: string;
};
const PRICE_KEYS = [
"price",
"freight",
"amount",
"cost",
"total",
"rawfreight",
"rate",
"basequoteprice",
"finalprice",
] as const;
function readPrice(tier: unknown): number | null {
if (tier == null || typeof tier !== "object" || Array.isArray(tier)) {
return null;
}
const obj = tier as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const nk = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
if (!PRICE_KEYS.includes(nk as (typeof PRICE_KEYS)[number])) {
continue;
}
const val = obj[key];
if (typeof val === "number" && val > 0) {
return val;
}
if (typeof val === "string") {
const m = val.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
if (m) {
const n = Number(m[1]);
if (n > 0) {
return n;
}
}
}
}
return null;
}
function getAt(obj: unknown, ...keys: string[]): unknown {
let cur = obj;
for (const key of keys) {
if (cur == null || typeof cur !== "object" || Array.isArray(cur)) {
return undefined;
}
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
function isPricedTier(tier: unknown): boolean {
return readPrice(tier) !== null;
}
/** 契约校验仅作用于 normalized rates */
export function inspectQuoteContractOnRates(
rates: QuoteContractRates,
): { ok: true; rates: QuoteContractRates } | { ok: false; violations: QuoteContractViolation[] } {
const violations: QuoteContractViolation[] = [];
if (!isPricedTier(getAt(rates, "standard", "lowest"))) {
violations.push({
path: "rates.standard.lowest",
reason: "缺失或无效价格",
});
}
const fastestTop = getAt(rates, "fastest");
const fastestStd = getAt(rates, "standard", "fastest");
if (!isPricedTier(fastestTop) && !isPricedTier(fastestStd)) {
violations.push({
path: "rates.fastest",
reason: "rates.fastest 与 rates.standard.fastest 均缺失或无效价格",
});
}
const guaranteed = rates.guaranteed;
if (
guaranteed == null ||
typeof guaranteed !== "object" ||
Array.isArray(guaranteed)
) {
violations.push({
path: "rates.guaranteed",
reason: "缺失 guaranteed 分支",
});
} else if (!isPricedTier(resolveGuaranteedLowestTier(guaranteed))) {
violations.push({
path: "rates.guaranteed.lowest",
reason: "缺失 lowest/bestValue 或无效价格",
});
}
if (violations.length > 0) {
return { ok: false, violations };
}
return { ok: true, rates };
}
export function inspectQuoteContract(
body: unknown,
):
| { ok: true; rates: QuoteContractRates; canonical: CanonicalQuote }
| { ok: false; violations: QuoteContractViolation[] } {
const canonical = normalizeQuote(body);
if (!canonical) {
return {
ok: false,
violations: [{ path: "rates", reason: "normalizeQuote 未解析到 rates" }],
};
}
const checked = inspectQuoteContractOnRates(canonical.rates);
if (!checked.ok) {
return checked;
}
return { ok: true, rates: checked.rates, canonical };
}
export function satisfiesQuoteContract(body: unknown): boolean {
return inspectQuoteContract(body).ok;
}
export function assertQuoteContract(
body: unknown,
context?: string,
): QuoteContractRates {
const result = inspectQuoteContract(body);
if (!result.ok) {
const detail = result.violations
.map((v) => `${v.path}: ${v.reason}`)
.join("; ");
throw new RpaError(
"RPA_DATA_INVALID",
`报价契约校验失败${context ? ` (${context})` : ""}: ${detail}`,
{ retryable: false },
);
}
return result.rates;
}