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