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.

251 lines
6.8 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<MothershipAddressCandidate[]> {
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<string>();
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<MothershipAddressCandidate> {
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<MothershipAddressCandidate[]> {
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<MothershipAddressCandidate[]> {
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),
);
}