import type { AddressLookupInput, MothershipAddressCandidate, } from "@/modules/address/types"; import { AxelHttpClient } from "@/lib/axel/client"; import { getAxelCandidateVerifyTopK } from "@/lib/axel/constants"; import type { AxelSearchCandidate } from "@/lib/axel/pick-candidate"; import { expandDirectionalStreet, sortMothershipCandidatesForDraft, } from "@/lib/address/street-normalize"; import { labelToCandidate } from "@/workers/rpa/address-suggestions"; const UNAVAILABLE_REASON = "MotherShip 无法定位此地址,请选择其他候选或修改街道信息"; function buildSearchQuery(address: AddressLookupInput): string { return [address.street, address.city, address.state, address.zip] .map((part) => part?.trim()) .filter(Boolean) .join(", "); } function parseStateFromSecondary(secondary: string): { city: string; state: string; zip: string; } { const m = secondary.match(/^([^,]+),\s*([A-Z]{2})(?:\s+(\d{5}(?:-\d{4})?))?/); if (m) { return { city: m[1]!.trim(), state: m[2]!, zip: m[3] ?? "" }; } const parts = secondary.split(",").map((p) => p.trim()); return { city: parts[0] ?? "", state: parts[1]?.slice(0, 2) ?? "", zip: "", }; } /** axel search 行 → 候选;展示文案与 API description 一致 */ export function axelSearchRowToCandidate( row: AxelSearchCandidate, query: AddressLookupInput, ): MothershipAddressCandidate | null { const description = row.description.trim(); const optionId = row.placeId.trim(); if (!description || !optionId) { return null; } const parsed = labelToCandidate(description, query); if (parsed) { return { ...parsed, option_id: optionId, display_label: description, formatted_address: description, }; } const mainText = (row.mainText ?? description.split(",")[0] ?? "").trim(); const secondary = (row.secondaryText ?? "").trim(); const tail = parseStateFromSecondary(secondary); if (!mainText) { return null; } return { option_id: optionId, display_label: description, formatted_address: description, street: mainText, city: tail.city || query.city, state: tail.state || query.state, zip: tail.zip || query.zip, }; } function sortCandidatesForQuery( candidates: MothershipAddressCandidate[], query: AddressLookupInput, ): MothershipAddressCandidate[] { return sortMothershipCandidatesForDraft(candidates, { street: query.street, city: query.city, state: query.state, }); } async function searchSideCandidates( client: AxelHttpClient, query: AddressLookupInput, ): Promise { const queries = [buildSearchQuery(query)]; const expandedStreet = expandDirectionalStreet(query.street); if (expandedStreet && expandedStreet.toLowerCase() !== query.street.trim().toLowerCase()) { queries.push( buildSearchQuery({ ...query, street: expandedStreet, }), ); } const rows: AxelSearchCandidate[] = []; for (const searchQuery of queries) { const batch = await client.searchLocation(searchQuery); rows.push(...batch); } return dedupeByOptionId( rows .map((row) => axelSearchRowToCandidate(row, query)) .filter((row): row is MothershipAddressCandidate => row != null), ); } function dedupeByOptionId( candidates: MothershipAddressCandidate[], ): MothershipAddressCandidate[] { const seen = new Set(); const out: MothershipAddressCandidate[] = []; for (const row of candidates) { if (seen.has(row.option_id)) { continue; } seen.add(row.option_id); out.push(row); } return out; } async function verifyCandidate( client: AxelHttpClient, candidate: MothershipAddressCandidate, ): Promise { try { await client.fetchPlace(candidate.option_id); return { ...candidate, selectable: true }; } catch { return { ...candidate, selectable: false, unavailable_reason: UNAVAILABLE_REASON, }; } } async function verifyCandidates( client: AxelHttpClient, candidates: MothershipAddressCandidate[], ): Promise { if (candidates.length === 0) { return []; } const topK = getAxelCandidateVerifyTopK(); const toVerify = candidates.slice(0, topK); const rest = candidates.slice(topK); const verified = await Promise.all( toVerify.map((c) => verifyCandidate(client, c)), ); const unverified = rest.map((c) => ({ ...c, selectable: true as const })); return [...verified, ...unverified]; } function assertHasSelectable( candidates: MothershipAddressCandidate[], sideLabel: string, ): void { if (candidates.some((c) => c.selectable !== false)) { return; } throw new Error( `${sideLabel}地址在 MotherShip 均无法定位,请修改街道或城市后重试`, ); } export async function resolveAxelMothershipCandidates(input: { pickup: AddressLookupInput; delivery: AddressLookupInput; }): Promise<{ pickup_candidates: MothershipAddressCandidate[]; delivery_candidates: MothershipAddressCandidate[]; }> { const client = await AxelHttpClient.create(); const [pickupRaw, deliveryRaw] = await Promise.all([ searchSideCandidates(client, input.pickup), searchSideCandidates(client, input.delivery), ]); const pickup_candidates = await verifyCandidates( client, sortCandidatesForQuery(pickupRaw, input.pickup), ); const delivery_candidates = await verifyCandidates( client, sortCandidatesForQuery(deliveryRaw, input.delivery), ); if (pickup_candidates.length === 0) { throw new Error("MotherShip 未返回提货地址候选"); } if (delivery_candidates.length === 0) { throw new Error("MotherShip 未返回派送地址候选"); } assertHasSelectable(pickup_candidates, "提货"); assertHasSelectable(delivery_candidates, "派送"); return { pickup_candidates, delivery_candidates }; } /** * 登录后一级表单:单 query 联想(键入下拉)。 * 仅透传 MotherShip Axel `location/search` 的 googlePlacesResults(含 placeId), * 禁止本系统伪造候选;并对 TopK 做 fetchPlace 校验,不可定位则 selectable=false。 */ export async function resolveAxelMothershipSuggest( query: string, ): Promise { const trimmed = query.trim(); if (trimmed.length < 3) { return []; } const lookup: AddressLookupInput = { street: trimmed, city: "", state: "", zip: "", }; const client = await AxelHttpClient.create(); const rows = await client.searchLocation(trimmed); const mapped = dedupeByOptionId( rows .map((row) => axelSearchRowToCandidate(row, lookup)) .filter((row): row is MothershipAddressCandidate => row != null) .filter((c) => c.option_id.trim().length > 0), ); if (mapped.length === 0) { return []; } return verifyCandidates( client, sortCandidatesForQuery(mapped, lookup), ); }