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