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.

204 lines
5.6 KiB

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 { 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 sortByStreetNumber(
candidates: MothershipAddressCandidate[],
query: AddressLookupInput,
): MothershipAddressCandidate[] {
const streetNum = query.street.match(/^\d+/)?.[0];
if (!streetNum) {
return candidates;
}
return [...candidates].sort((a, b) => {
const aHas = `${a.street} ${a.display_label}`.includes(streetNum);
const bHas = `${b.street} ${b.display_label}`.includes(streetNum);
if (aHas === bHas) {
return 0;
}
return aHas ? -1 : 1;
});
}
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 [pickupRows, deliveryRows] = await Promise.all([
client.searchLocation(buildSearchQuery(input.pickup)),
client.searchLocation(buildSearchQuery(input.delivery)),
]);
const pickup_candidates = await verifyCandidates(
client,
sortByStreetNumber(
dedupeByOptionId(
pickupRows
.map((row) => axelSearchRowToCandidate(row, input.pickup))
.filter((row): row is MothershipAddressCandidate => row != null),
),
input.pickup,
),
);
const delivery_candidates = await verifyCandidates(
client,
sortByStreetNumber(
dedupeByOptionId(
deliveryRows
.map((row) => axelSearchRowToCandidate(row, input.delivery))
.filter((row): row is MothershipAddressCandidate => row != null),
),
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 };
}