|
|
import type { QuoteItem } from "@/modules/providers/quote-provider";
|
|
|
import {
|
|
|
normalizeMotherShipRateOption,
|
|
|
normalizeMotherShipServiceLevel,
|
|
|
} from "@/lib/constants/mothership-ui-tiers";
|
|
|
import { isAxelQuoteResponseUrl } from "@/workers/rpa/quote-capture/axel-contract";
|
|
|
import { satisfiesQuoteContract } from "@/workers/rpa/quote-capture/quote-contract-guard";
|
|
|
import { normalizeQuote } from "@/workers/rpa/quote-capture/quote-normalize";
|
|
|
import type { QuoteCaptureSignals, QuoteDecodeResult } from "@/workers/rpa/quote-capture/types";
|
|
|
|
|
|
type PartialTier = {
|
|
|
serviceLevel: QuoteItem["serviceLevel"];
|
|
|
rateOption: QuoteItem["rateOption"];
|
|
|
rawFreight: number;
|
|
|
surcharges: number;
|
|
|
transitDays: string;
|
|
|
transitDescription: string;
|
|
|
carrier: string;
|
|
|
};
|
|
|
|
|
|
const MAX_WALK_DEPTH = 24;
|
|
|
|
|
|
const SERVICE_LEVEL_ALIASES: Record<string, string> = {
|
|
|
standard: "standard",
|
|
|
guaranteed: "guaranteed",
|
|
|
guarantee: "guaranteed",
|
|
|
dedicated: "dedicated",
|
|
|
dedicatedtruck: "dedicated",
|
|
|
truckload: "dedicated",
|
|
|
fulltruckload: "dedicated",
|
|
|
ftl: "dedicated",
|
|
|
标准: "standard",
|
|
|
保证送达: "guaranteed",
|
|
|
专属卡车: "dedicated",
|
|
|
专属货车: "dedicated",
|
|
|
};
|
|
|
|
|
|
const RATE_OPTION_ALIASES: Record<string, QuoteItem["rateOption"]> = {
|
|
|
lowest: "lowest",
|
|
|
cheapest: "lowest",
|
|
|
economy: "lowest",
|
|
|
fastest: "fastest",
|
|
|
expedited: "fastest",
|
|
|
express: "fastest",
|
|
|
bestvalue: "bestValue",
|
|
|
最低价格: "lowest",
|
|
|
最快递送: "fastest",
|
|
|
最优性价比: "bestValue",
|
|
|
};
|
|
|
|
|
|
const PRICE_FIELD_KEYS = [
|
|
|
"price",
|
|
|
"freight",
|
|
|
"amount",
|
|
|
"cost",
|
|
|
"total",
|
|
|
"rawfreight",
|
|
|
"rawtotal",
|
|
|
"rate",
|
|
|
"value",
|
|
|
"basequoteprice",
|
|
|
"finalprice",
|
|
|
] as const;
|
|
|
|
|
|
const TRANSIT_FIELD_KEYS = [
|
|
|
"transitdays",
|
|
|
"transit_days",
|
|
|
"estimatedtransitdays",
|
|
|
"estimatedtransit",
|
|
|
"deliverydays",
|
|
|
"transit",
|
|
|
"eta",
|
|
|
"days",
|
|
|
] as const;
|
|
|
|
|
|
function normalizeKey(key: string): string {
|
|
|
return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
|
}
|
|
|
|
|
|
function keyToServiceLevel(key: string): string | null {
|
|
|
const n = normalizeKey(key);
|
|
|
const fromAlias = SERVICE_LEVEL_ALIASES[n];
|
|
|
if (fromAlias) {
|
|
|
return fromAlias;
|
|
|
}
|
|
|
return normalizeMotherShipServiceLevel(key);
|
|
|
}
|
|
|
|
|
|
function keyToRateOption(key: string): QuoteItem["rateOption"] | null {
|
|
|
const n = normalizeKey(key);
|
|
|
return RATE_OPTION_ALIASES[n] ?? null;
|
|
|
}
|
|
|
|
|
|
function mapSubKeyToRateOption(
|
|
|
_serviceLevel: string,
|
|
|
subKey: string,
|
|
|
): QuoteItem["rateOption"] | null {
|
|
|
const ro = keyToRateOption(subKey) ?? normalizeMotherShipRateOption(subKey);
|
|
|
if (ro) {
|
|
|
return ro as QuoteItem["rateOption"];
|
|
|
}
|
|
|
if (normalizeKey(subKey) === "bestvalue") {
|
|
|
return "bestValue";
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function readStringField(obj: Record<string, unknown>, keys: readonly string[]): string | null {
|
|
|
for (const key of keys) {
|
|
|
const val = obj[key];
|
|
|
if (typeof val === "string" && val.trim()) {
|
|
|
return val.trim();
|
|
|
}
|
|
|
if (typeof val === "number" && Number.isFinite(val)) {
|
|
|
return String(val);
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function readNumericFromKeys(
|
|
|
obj: Record<string, unknown>,
|
|
|
keys: readonly string[],
|
|
|
): number | null {
|
|
|
for (const want of keys) {
|
|
|
for (const key of Object.keys(obj)) {
|
|
|
if (normalizeKey(key) !== normalizeKey(want)) {
|
|
|
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 readPrice(obj: Record<string, unknown>): number | null {
|
|
|
// 为何:MotherShip UI 展示 finalPrice;price 常为承运商标价与 UI 不一致
|
|
|
return (
|
|
|
readNumericFromKeys(obj, ["finalprice"]) ??
|
|
|
readNumericFromKeys(obj, ["price"])
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function readTransit(obj: Record<string, unknown>): string {
|
|
|
const daysVal = obj.days ?? obj.transitDays;
|
|
|
if (typeof daysVal === "number") {
|
|
|
if (daysVal > 0) {
|
|
|
return `Est. ${daysVal} business days`;
|
|
|
}
|
|
|
if (daysVal === 0) {
|
|
|
return "0 business days";
|
|
|
}
|
|
|
}
|
|
|
const desc = readStringField(obj, [
|
|
|
"serviceCommitmentDescription",
|
|
|
"transitDescription",
|
|
|
"description",
|
|
|
"label",
|
|
|
]);
|
|
|
if (desc) {
|
|
|
return desc;
|
|
|
}
|
|
|
for (const key of Object.keys(obj)) {
|
|
|
const nk = normalizeKey(key);
|
|
|
if (!TRANSIT_FIELD_KEYS.some((t) => nk.includes(t.replace(/_/g, "")))) {
|
|
|
continue;
|
|
|
}
|
|
|
const val = obj[key];
|
|
|
if (typeof val === "string" && val.trim()) {
|
|
|
return val.trim();
|
|
|
}
|
|
|
if (typeof val === "number" && val > 0) {
|
|
|
return `${val} business days`;
|
|
|
}
|
|
|
}
|
|
|
return "—";
|
|
|
}
|
|
|
|
|
|
function inferServiceLevel(obj: Record<string, unknown>): string | null {
|
|
|
const fields = ["serviceLevel", "service_level", "serviceType", "service_type", "type", "name", "tier"];
|
|
|
for (const key of fields) {
|
|
|
const val = obj[key];
|
|
|
if (typeof val !== "string") {
|
|
|
continue;
|
|
|
}
|
|
|
const lower = val.toLowerCase();
|
|
|
if (lower.includes("guarantee") || val.includes("保证")) {
|
|
|
return "guaranteed";
|
|
|
}
|
|
|
if (
|
|
|
lower.includes("dedicated") ||
|
|
|
lower.includes("truckload") ||
|
|
|
lower.includes("ftl") ||
|
|
|
val.includes("专属")
|
|
|
) {
|
|
|
return "dedicated";
|
|
|
}
|
|
|
if (lower.includes("standard") || val.includes("标准")) {
|
|
|
return "standard";
|
|
|
}
|
|
|
const fromKey = keyToServiceLevel(val);
|
|
|
if (fromKey) {
|
|
|
return fromKey;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function inferRateOption(obj: Record<string, unknown>): QuoteItem["rateOption"] | null {
|
|
|
const fields = ["rateOption", "rate_option", "option", "speed", "name", "label", "serviceType", "service_type"];
|
|
|
for (const key of fields) {
|
|
|
const val = obj[key];
|
|
|
if (typeof val !== "string") {
|
|
|
continue;
|
|
|
}
|
|
|
const lower = val.toLowerCase();
|
|
|
if (lower.includes("fast") || lower.includes("exped") || val.includes("最快")) {
|
|
|
return "fastest";
|
|
|
}
|
|
|
if (lower.includes("low") || lower.includes("cheap") || val.includes("最低")) {
|
|
|
return "lowest";
|
|
|
}
|
|
|
if (lower.includes("bestvalue") || lower.includes("best value") || val.includes("最优")) {
|
|
|
return "bestValue";
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function readCarrierName(obj: Record<string, unknown>): string {
|
|
|
const carrierInfo = obj.carrierInfo;
|
|
|
if (
|
|
|
carrierInfo != null &&
|
|
|
typeof carrierInfo === "object" &&
|
|
|
!Array.isArray(carrierInfo)
|
|
|
) {
|
|
|
const name = readStringField(carrierInfo as Record<string, unknown>, [
|
|
|
"name",
|
|
|
"carrierName",
|
|
|
]);
|
|
|
if (name) {
|
|
|
return name;
|
|
|
}
|
|
|
}
|
|
|
return (
|
|
|
readStringField(obj, ["carrierName", "carrier", "providerName"]) ??
|
|
|
"Mothership"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function pushTier(
|
|
|
acc: PartialTier[],
|
|
|
serviceLevel: string,
|
|
|
rateOption: QuoteItem["rateOption"],
|
|
|
obj: Record<string, unknown>,
|
|
|
): void {
|
|
|
const rawFreight = readPrice(obj);
|
|
|
if (rawFreight === null || rawFreight <= 0) {
|
|
|
return;
|
|
|
}
|
|
|
const surcharges =
|
|
|
typeof obj.surcharges === "number" && obj.surcharges >= 0
|
|
|
? obj.surcharges
|
|
|
: typeof obj.fees === "number" && obj.fees >= 0
|
|
|
? obj.fees
|
|
|
: 0;
|
|
|
const transit = readTransit(obj);
|
|
|
acc.push({
|
|
|
serviceLevel,
|
|
|
rateOption,
|
|
|
rawFreight,
|
|
|
surcharges,
|
|
|
transitDays: transit,
|
|
|
transitDescription: transit,
|
|
|
carrier: readCarrierName(obj),
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function walkNestedServiceRates(
|
|
|
obj: Record<string, unknown>,
|
|
|
acc: PartialTier[],
|
|
|
depth: number,
|
|
|
): void {
|
|
|
if (depth > MAX_WALK_DEPTH) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
|
const sl = keyToServiceLevel(key);
|
|
|
if (!sl || typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
|
continue;
|
|
|
}
|
|
|
const nested = value as Record<string, unknown>;
|
|
|
const directRo = keyToRateOption(key);
|
|
|
if (directRo) {
|
|
|
pushTier(acc, sl, directRo, nested);
|
|
|
continue;
|
|
|
}
|
|
|
for (const [subKey, subVal] of Object.entries(nested)) {
|
|
|
const ro = mapSubKeyToRateOption(sl, subKey);
|
|
|
if (!ro || typeof subVal !== "object" || subVal === null || Array.isArray(subVal)) {
|
|
|
continue;
|
|
|
}
|
|
|
pushTier(acc, sl, ro, subVal as Record<string, unknown>);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function walkJson(node: unknown, acc: PartialTier[], depth = 0): void {
|
|
|
if (depth > MAX_WALK_DEPTH || node == null) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (Array.isArray(node)) {
|
|
|
for (const item of node) {
|
|
|
walkJson(item, acc, depth + 1);
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (typeof node !== "object") {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const obj = node as Record<string, unknown>;
|
|
|
walkNestedServiceRates(obj, acc, depth);
|
|
|
|
|
|
const sl = inferServiceLevel(obj);
|
|
|
const ro = inferRateOption(obj);
|
|
|
if (sl) {
|
|
|
pushTier(acc, sl, ro ?? "lowest", obj);
|
|
|
}
|
|
|
|
|
|
for (const value of Object.values(obj)) {
|
|
|
walkJson(value, acc, depth + 1);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function dedupePartials(partials: PartialTier[]): PartialTier[] {
|
|
|
const map = new Map<string, PartialTier>();
|
|
|
for (const tier of partials) {
|
|
|
const key = `${tier.serviceLevel}/${tier.rateOption}`;
|
|
|
if (!map.has(key)) {
|
|
|
map.set(key, tier);
|
|
|
}
|
|
|
}
|
|
|
return [...map.values()];
|
|
|
}
|
|
|
|
|
|
function partialsToQuoteItems(partials: PartialTier[]): QuoteItem[] {
|
|
|
return dedupePartials(partials).map((tier) => ({
|
|
|
serviceLevel: tier.serviceLevel,
|
|
|
rateOption: tier.rateOption,
|
|
|
carrier: tier.carrier,
|
|
|
transitDays: tier.transitDays,
|
|
|
transitDescription: tier.transitDescription,
|
|
|
rawFreight: tier.rawFreight,
|
|
|
surcharges: tier.surcharges,
|
|
|
rawTotal: tier.rawFreight + tier.surcharges,
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
/** UI 档位:叶节点 finalPrice/price;或经 id 关联 availableRates 解析完整对象 */
|
|
|
function resolveUiTierLeaf(tier: unknown): Record<string, unknown> | null {
|
|
|
if (tier == null || typeof tier !== "object" || Array.isArray(tier)) {
|
|
|
return null;
|
|
|
}
|
|
|
const obj = tier as Record<string, unknown>;
|
|
|
if (readNumericFromKeys(obj, ["finalprice", "price"]) != null) {
|
|
|
return obj;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function resolveTierFromRatesSlot(
|
|
|
slotVal: unknown,
|
|
|
availableRates: Record<string, unknown> | null,
|
|
|
): Record<string, unknown> | null {
|
|
|
const leaf = resolveUiTierLeaf(slotVal);
|
|
|
if (leaf) {
|
|
|
return leaf;
|
|
|
}
|
|
|
if (availableRates == null) {
|
|
|
return null;
|
|
|
}
|
|
|
let rateId: string | null = null;
|
|
|
if (typeof slotVal === "string" && slotVal.trim()) {
|
|
|
rateId = slotVal.trim();
|
|
|
} else if (slotVal != null && typeof slotVal === "object" && !Array.isArray(slotVal)) {
|
|
|
const id = (slotVal as Record<string, unknown>).id;
|
|
|
if (typeof id === "string" && id.trim()) {
|
|
|
rateId = id.trim();
|
|
|
}
|
|
|
}
|
|
|
if (!rateId) {
|
|
|
return null;
|
|
|
}
|
|
|
const ref = availableRates[rateId];
|
|
|
if (ref == null || typeof ref !== "object" || Array.isArray(ref)) {
|
|
|
return null;
|
|
|
}
|
|
|
return resolveUiTierLeaf(ref) ? (ref as Record<string, unknown>) : null;
|
|
|
}
|
|
|
|
|
|
/** MotherShip 返回什么就解析什么:至少 1 条有效运价即可 */
|
|
|
function hasMinimumTiers(items: QuoteItem[]): boolean {
|
|
|
return items.some((i) => i.rawFreight > 0);
|
|
|
}
|
|
|
|
|
|
/** 解码 sign-up-quote?quote-details=(URL 编码 JSON,可能多层) */
|
|
|
export function decodeQuoteDetailsParam(raw: string): unknown | null {
|
|
|
let current = raw.trim();
|
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
|
if (current.startsWith("{") || current.startsWith("[")) {
|
|
|
try {
|
|
|
return JSON.parse(current) as unknown;
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let progressed = false;
|
|
|
|
|
|
try {
|
|
|
const urlDecoded = decodeURIComponent(current);
|
|
|
if (urlDecoded !== current) {
|
|
|
current = urlDecoded;
|
|
|
progressed = true;
|
|
|
}
|
|
|
} catch {
|
|
|
/* 非 URL 编码 */
|
|
|
}
|
|
|
|
|
|
if (!progressed) {
|
|
|
try {
|
|
|
const fromB64 = Buffer.from(current, "base64").toString("utf8");
|
|
|
if (fromB64 && fromB64 !== current) {
|
|
|
current = fromB64;
|
|
|
progressed = true;
|
|
|
}
|
|
|
} catch {
|
|
|
/* 非 base64 */
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!progressed) {
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (current.startsWith("{") || current.startsWith("[")) {
|
|
|
try {
|
|
|
return JSON.parse(current) as unknown;
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function extractTiersFromServiceBlock(
|
|
|
partials: PartialTier[],
|
|
|
serviceLevel: string,
|
|
|
block: Record<string, unknown>,
|
|
|
availableRates: Record<string, unknown> | null,
|
|
|
): void {
|
|
|
for (const [subKey, subVal] of Object.entries(block)) {
|
|
|
const ro = mapSubKeyToRateOption(serviceLevel, subKey);
|
|
|
if (!ro) {
|
|
|
continue;
|
|
|
}
|
|
|
const tier = resolveTierFromRatesSlot(subVal, availableRates);
|
|
|
if (tier) {
|
|
|
pushTier(partials, serviceLevel, ro, tier);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function extractTiersFromRates(
|
|
|
rates: Record<string, unknown>,
|
|
|
availableRates: Record<string, unknown> | null,
|
|
|
): PartialTier[] {
|
|
|
const partials: PartialTier[] = [];
|
|
|
const skipTopLevel = new Set(["fastest"]);
|
|
|
|
|
|
for (const [key, block] of Object.entries(rates)) {
|
|
|
if (skipTopLevel.has(key)) {
|
|
|
continue;
|
|
|
}
|
|
|
const serviceLevel = keyToServiceLevel(key);
|
|
|
if (
|
|
|
!serviceLevel ||
|
|
|
block == null ||
|
|
|
typeof block !== "object" ||
|
|
|
Array.isArray(block)
|
|
|
) {
|
|
|
continue;
|
|
|
}
|
|
|
extractTiersFromServiceBlock(
|
|
|
partials,
|
|
|
serviceLevel,
|
|
|
block as Record<string, unknown>,
|
|
|
availableRates,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const fastestTop = resolveTierFromRatesSlot(rates.fastest, availableRates);
|
|
|
if (
|
|
|
fastestTop &&
|
|
|
!partials.some(
|
|
|
(p) => p.serviceLevel === "standard" && p.rateOption === "fastest",
|
|
|
)
|
|
|
) {
|
|
|
pushTier(partials, "standard", "fastest", fastestTop);
|
|
|
}
|
|
|
|
|
|
return partials;
|
|
|
}
|
|
|
|
|
|
function readAvailableRates(body: unknown): Record<string, unknown> | null {
|
|
|
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
|
return null;
|
|
|
}
|
|
|
const data = (body as Record<string, unknown>).data;
|
|
|
if (data == null || typeof data !== "object" || Array.isArray(data)) {
|
|
|
return null;
|
|
|
}
|
|
|
const availableRates = (data as Record<string, unknown>).availableRates;
|
|
|
if (
|
|
|
availableRates == null ||
|
|
|
typeof availableRates !== "object" ||
|
|
|
Array.isArray(availableRates)
|
|
|
) {
|
|
|
return null;
|
|
|
}
|
|
|
return availableRates as Record<string, unknown>;
|
|
|
}
|
|
|
|
|
|
/** MotherShip UI 档位名仅以 rates.{standard|guaranteed} 下出现的子键为准 */
|
|
|
function listUiRateOptionsFromRatesBlock(
|
|
|
rates: Record<string, unknown> | undefined,
|
|
|
serviceLevel: string,
|
|
|
): Set<QuoteItem["rateOption"]> | null {
|
|
|
const block = rates?.[serviceLevel];
|
|
|
if (block == null || typeof block !== "object" || Array.isArray(block)) {
|
|
|
return null;
|
|
|
}
|
|
|
const allowed = new Set<QuoteItem["rateOption"]>();
|
|
|
for (const subKey of Object.keys(block as Record<string, unknown>)) {
|
|
|
const ro = mapSubKeyToRateOption(serviceLevel, subKey);
|
|
|
if (ro) {
|
|
|
allowed.add(ro);
|
|
|
}
|
|
|
}
|
|
|
return allowed.size > 0 ? allowed : null;
|
|
|
}
|
|
|
|
|
|
/** 仅 rates.{serviceLevel} 下声明的子键允许从 availableRates 补全(与官网 Tab 一致) */
|
|
|
function listAllowedRateOptionsForSupplement(
|
|
|
rates: Record<string, unknown> | undefined,
|
|
|
serviceLevel: string,
|
|
|
): Set<QuoteItem["rateOption"]> | null {
|
|
|
if (!rates) {
|
|
|
return null;
|
|
|
}
|
|
|
return listUiRateOptionsFromRatesBlock(rates, serviceLevel);
|
|
|
}
|
|
|
|
|
|
function isRateOptionAllowedForSupplement(
|
|
|
rates: Record<string, unknown> | undefined,
|
|
|
serviceLevel: string,
|
|
|
rateOption: QuoteItem["rateOption"],
|
|
|
): boolean {
|
|
|
const allowed = listAllowedRateOptionsForSupplement(rates, serviceLevel);
|
|
|
if (allowed == null || allowed.size === 0) {
|
|
|
return false;
|
|
|
}
|
|
|
return allowed.has(rateOption);
|
|
|
}
|
|
|
|
|
|
function hasPartialTier(
|
|
|
partials: PartialTier[],
|
|
|
serviceLevel: string,
|
|
|
rateOption: QuoteItem["rateOption"],
|
|
|
): boolean {
|
|
|
return partials.some(
|
|
|
(p) => p.serviceLevel === serviceLevel && p.rateOption === rateOption,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function mergePartials(target: PartialTier[], source: PartialTier[]): void {
|
|
|
for (const tier of source) {
|
|
|
if (!hasPartialTier(target, tier.serviceLevel, tier.rateOption)) {
|
|
|
target.push(tier);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* data.availableRates 补全:仅填充 rates.* 已声明槽位(显式 serviceType 或 id 引用),禁止 customRate 推断
|
|
|
*/
|
|
|
function extractTiersFromAvailableRates(
|
|
|
body: unknown,
|
|
|
ratesFromBody?: Record<string, unknown>,
|
|
|
): PartialTier[] {
|
|
|
const availableRates = readAvailableRates(body);
|
|
|
if (!availableRates) {
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
const partials: PartialTier[] = [];
|
|
|
|
|
|
for (const entry of Object.values(availableRates)) {
|
|
|
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
|
continue;
|
|
|
}
|
|
|
const obj = entry as Record<string, unknown>;
|
|
|
if (!resolveUiTierLeaf(obj)) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const serviceLevel = inferServiceLevel(obj) ?? "standard";
|
|
|
const serviceType = String(obj.serviceType ?? "").toLowerCase();
|
|
|
if (!serviceType || serviceType === "customrate") {
|
|
|
continue;
|
|
|
}
|
|
|
const rateOption =
|
|
|
keyToRateOption(serviceType) ??
|
|
|
mapSubKeyToRateOption(serviceLevel, serviceType);
|
|
|
if (!rateOption) {
|
|
|
continue;
|
|
|
}
|
|
|
if (
|
|
|
isRateOptionAllowedForSupplement(ratesFromBody, serviceLevel, rateOption)
|
|
|
) {
|
|
|
pushTier(partials, serviceLevel, rateOption, obj);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return partials;
|
|
|
}
|
|
|
|
|
|
function mergeAvailableRatesSupplement(
|
|
|
partials: PartialTier[],
|
|
|
body: unknown,
|
|
|
rates: Record<string, unknown> | undefined,
|
|
|
): void {
|
|
|
mergePartials(partials, extractTiersFromAvailableRates(body, rates));
|
|
|
}
|
|
|
|
|
|
function extractFromContractBodies(
|
|
|
bodies: unknown[],
|
|
|
): QuoteItem[] | null {
|
|
|
const partials: PartialTier[] = [];
|
|
|
for (const body of bodies) {
|
|
|
const canonical = normalizeQuote(body);
|
|
|
if (!canonical) {
|
|
|
continue;
|
|
|
}
|
|
|
const availableRates = readAvailableRates(body);
|
|
|
partials.push(...extractTiersFromRates(canonical.rates, availableRates));
|
|
|
mergeAvailableRatesSupplement(partials, body, canonical.rates);
|
|
|
}
|
|
|
const items = partialsToQuoteItems(partials);
|
|
|
return hasMinimumTiers(items) ? items : null;
|
|
|
}
|
|
|
|
|
|
/** 直连 axel/quote:仅 rates.* 声明槽位 + id/空槽 availableRates 补全,不推断 customRate */
|
|
|
export function decodeAxelQuoteBodyLenient(body: unknown): QuoteItem[] | null {
|
|
|
const partials: PartialTier[] = [];
|
|
|
const canonical = normalizeQuote(body);
|
|
|
const rates = canonical?.rates;
|
|
|
const availableRates = readAvailableRates(body);
|
|
|
if (rates) {
|
|
|
partials.push(...extractTiersFromRates(rates, availableRates));
|
|
|
}
|
|
|
|
|
|
mergeAvailableRatesSupplement(partials, body, rates);
|
|
|
|
|
|
const items = partialsToQuoteItems(partials);
|
|
|
return hasMinimumTiers(items) ? items : null;
|
|
|
}
|
|
|
|
|
|
function orderContractPayloads(
|
|
|
payloads: QuoteCaptureSignals["contractPayloads"],
|
|
|
): QuoteCaptureSignals["contractPayloads"] {
|
|
|
return [...payloads].sort((a, b) => {
|
|
|
const rank = (url: string) => (isAxelQuoteResponseUrl(url) ? 0 : 1);
|
|
|
return rank(a.url) - rank(b.url);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function tryLenientAxelDecode(
|
|
|
signals: QuoteCaptureSignals,
|
|
|
): QuoteDecodeResult | null {
|
|
|
const onSignUpQuote = signals.resultUrl.includes("sign-up-quote");
|
|
|
const candidates = [
|
|
|
...orderContractPayloads(signals.contractPayloads),
|
|
|
...signals.jsonPayloads,
|
|
|
];
|
|
|
for (const payload of candidates) {
|
|
|
if (!isAxelQuoteResponseUrl(payload.url) && !onSignUpQuote) {
|
|
|
continue;
|
|
|
}
|
|
|
const items = decodeAxelQuoteBodyLenient(payload.body);
|
|
|
if (items && hasMinimumTiers(items)) {
|
|
|
console.log(
|
|
|
`[rpa] quote-decode: lenient axel/quote url=${payload.url} tiers=${items.length}`,
|
|
|
);
|
|
|
return { items, source: "lenient" };
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 契约 JSON 响应 → 四档报价;sign-up-quote 页 axel/quote 结构不全时走 lenient */
|
|
|
export function decodeQuotePayloads(
|
|
|
signals: QuoteCaptureSignals,
|
|
|
): QuoteDecodeResult | null {
|
|
|
if (signals.contractPayloads.length === 0) {
|
|
|
return tryLenientAxelDecode(signals);
|
|
|
}
|
|
|
|
|
|
const ordered = orderContractPayloads(signals.contractPayloads);
|
|
|
const strictOk = ordered.every((p) => satisfiesQuoteContract(p.body));
|
|
|
|
|
|
if (strictOk) {
|
|
|
const bodies = ordered.map((p) => p.body);
|
|
|
const items = extractFromContractBodies(bodies);
|
|
|
if (items && hasMinimumTiers(items)) {
|
|
|
return { items, source: "contract" };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return tryLenientAxelDecode(signals);
|
|
|
}
|
|
|
|
|
|
/** @deprecated Network-First 架构禁止 DOM 内嵌 JSON 报价提取 */
|
|
|
export function decodeEmbeddedJsonBodies(
|
|
|
_bodies: unknown[],
|
|
|
): QuoteDecodeResult | null {
|
|
|
return null;
|
|
|
}
|