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