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.

243 lines
7.3 KiB

import {
axelPlaceFetchCandidates,
normalizeAxelPlaceId,
} from "@/workers/rpa/axel-selection-bridge";
import {
AXEL_ORIGIN,
AXEL_PLACE_BASE,
AXEL_QUOTE_RETRIES,
AXEL_QUOTE_TIMEOUT_MS,
AXEL_QUOTE_URL,
AXEL_REFERER,
AXEL_SEARCH_URL,
DEFAULT_AXEL_USER_AGENT,
} from "@/lib/axel/constants";
import {
pickBestSearchCandidate,
type AxelSearchCandidate,
} from "@/lib/axel/pick-candidate";
import {
buildAxelShipmentPayload,
type AxelCargoInput,
type AxelPlaceLocation,
} from "@/lib/axel/shipment";
import {
buildCookieHeader,
loadStorageState,
type PlaywrightStorageState,
} from "@/lib/axel/session";
import type { QuoteItem } from "@/modules/providers/quote-provider";
import { decodeAxelQuoteBodyLenient } from "@/workers/rpa/quote-capture/payload-decode";
import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator";
export type AxelQuoteCliInput = {
origin: string;
dest: string;
pallets: number;
weightLb: number;
lengthIn?: number;
widthIn?: number;
heightIn?: number;
storageStatePath?: string;
};
export type AxelQuoteCliResult = {
origin: { query: string; placeId: string; description: string };
dest: { query: string; placeId: string; description: string };
cargo: AxelCargoInput;
items: QuoteItem[];
ratesSummary: Record<string, Record<string, number | string | null>>;
};
type SearchResponseBody = {
googlePlacesResults?: AxelSearchCandidate[];
};
function defaultCargoDims(input: AxelQuoteCliInput): AxelCargoInput {
return {
palletCount: input.pallets,
weightLb: input.weightLb,
lengthIn: input.lengthIn ?? 48,
widthIn: input.widthIn ?? 40,
heightIn: input.heightIn ?? 45,
};
}
function summarizeAxelRates(
body: unknown,
): Record<string, Record<string, number | string | null>> {
const rates = (body as { data?: { rates?: unknown } })?.data?.rates;
if (!rates || typeof rates !== "object") {
return {};
}
const out: Record<string, Record<string, number | string | null>> = {};
for (const [sl, tiers] of Object.entries(rates as Record<string, unknown>)) {
if (tiers == null || typeof tiers !== "object" || Array.isArray(tiers)) {
continue;
}
out[sl] = {};
for (const [ro, tier] of Object.entries(tiers as Record<string, unknown>)) {
if (tier == null || typeof tier !== "object" || Array.isArray(tier)) {
continue;
}
const t = tier as Record<string, unknown>;
out[sl][ro] =
typeof t.finalPrice === "number"
? t.finalPrice
: typeof t.price === "number"
? t.price
: null;
if (typeof t.days === "number") {
out[sl][`${ro}_days`] = t.days;
}
}
}
return out;
}
export class AxelHttpClient {
private readonly cookieHeader: string;
constructor(state: PlaywrightStorageState) {
this.cookieHeader = buildCookieHeader(state);
}
static fromStorageStatePath(path?: string): AxelHttpClient {
return new AxelHttpClient(loadStorageState(path));
}
/** 缺会话时自动 bootstrap 后再构造客户端 */
static async create(path?: string): Promise<AxelHttpClient> {
const { loadStorageStateAsync } = await import("@/lib/axel/session");
return new AxelHttpClient(await loadStorageStateAsync(path));
}
private baseHeaders(): Record<string, string> {
return {
Accept: "*/*",
Origin: AXEL_ORIGIN,
Referer: AXEL_REFERER,
"User-Agent": DEFAULT_AXEL_USER_AGENT,
Cookie: this.cookieHeader,
};
}
async searchLocation(query: string): Promise<AxelSearchCandidate[]> {
const res = await fetch(AXEL_SEARCH_URL, {
method: "POST",
headers: {
...this.baseHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ queryString: query }),
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`axel/location/search HTTP ${res.status}: ${text.slice(0, 200)}`,
);
}
const body = JSON.parse(text) as SearchResponseBody;
return body.googlePlacesResults ?? [];
}
async fetchPlace(placeId: string): Promise<AxelPlaceLocation> {
for (const candidate of axelPlaceFetchCandidates(placeId)) {
const url = `${AXEL_PLACE_BASE}${encodeURIComponent(candidate)}`;
const res = await fetch(url, { headers: this.baseHeaders() });
if (!res.ok) {
continue;
}
return (await res.json()) as AxelPlaceLocation;
}
throw new Error(
`axel/location/place 失败 placeId=${normalizeAxelPlaceId(placeId)}`,
);
}
async postQuote(shipment: Record<string, unknown>): Promise<unknown> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= AXEL_QUOTE_RETRIES; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), AXEL_QUOTE_TIMEOUT_MS);
try {
const res = await fetch(AXEL_QUOTE_URL, {
method: "POST",
headers: {
...this.baseHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ shipment }),
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`axel/quote HTTP ${res.status}: ${text.slice(0, 300)}`,
);
}
return JSON.parse(text) as unknown;
} catch (err) {
lastError =
err instanceof Error ? err : new Error("axel/quote 请求失败");
if (attempt < AXEL_QUOTE_RETRIES) {
await new Promise((r) => setTimeout(r, 1_500 * (attempt + 1)));
}
} finally {
clearTimeout(timer);
}
}
throw lastError ?? new Error("axel/quote 请求失败");
}
}
export async function fetchAxelQuote(
input: AxelQuoteCliInput,
): Promise<AxelQuoteCliResult> {
const client = await AxelHttpClient.create(input.storageStatePath);
const cargo = defaultCargoDims(input);
const originCandidates = await client.searchLocation(input.origin);
const destCandidates = await client.searchLocation(input.dest);
const originPick = pickBestSearchCandidate(input.origin, originCandidates);
const destPick = pickBestSearchCandidate(input.dest, destCandidates);
if (!originPick?.placeId) {
throw new Error(`未找到提货地址候选: ${input.origin}`);
}
if (!destPick?.placeId) {
throw new Error(`未找到派送地址候选: ${input.dest}`);
}
const pickup = await client.fetchPlace(originPick.placeId);
const delivery = await client.fetchPlace(destPick.placeId);
const shipment = buildAxelShipmentPayload({
pickup,
delivery,
pickupDescription: originPick.description,
deliveryDescription: destPick.description,
cargo,
});
const body = await client.postQuote(shipment);
const decoded = decodeAxelQuoteBodyLenient(body);
if (!decoded?.length) {
throw new Error("axel/quote 响应无法解析为报价档位");
}
return {
origin: {
query: input.origin,
placeId: normalizeAxelPlaceId(originPick.placeId),
description: originPick.description,
},
dest: {
query: input.dest,
placeId: normalizeAxelPlaceId(destPick.placeId),
description: destPick.description,
},
cargo,
items: normalizeQuoteItems(decoded),
ratesSummary: summarizeAxelRates(body),
};
}