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>; }; 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> { const rates = (body as { data?: { rates?: unknown } })?.data?.rates; if (!rates || typeof rates !== "object") { return {}; } const out: Record> = {}; for (const [sl, tiers] of Object.entries(rates as Record)) { if (tiers == null || typeof tiers !== "object" || Array.isArray(tiers)) { continue; } out[sl] = {}; for (const [ro, tier] of Object.entries(tiers as Record)) { if (tier == null || typeof tier !== "object" || Array.isArray(tier)) { continue; } const t = tier as Record; 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)); } private baseHeaders(): Record { return { Accept: "*/*", Origin: AXEL_ORIGIN, Referer: AXEL_REFERER, "User-Agent": DEFAULT_AXEL_USER_AGENT, Cookie: this.cookieHeader, }; } async searchLocation(query: string): Promise { 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 { 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): Promise { 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 { const client = AxelHttpClient.fromStorageStatePath(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), }; }