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
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;
|
|
}
|