|
|
import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { AxelHttpClient } from "@/lib/axel/client";
|
|
|
import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate";
|
|
|
import {
|
|
|
buildAxelShipmentPayload,
|
|
|
type AxelPlaceLocation,
|
|
|
} from "@/lib/axel/shipment";
|
|
|
import { normalizeAxelPlaceId } from "@/workers/rpa/axel-selection-bridge";
|
|
|
import { decodeAxelQuoteBodyLenient } from "@/workers/rpa/quote-capture/payload-decode";
|
|
|
import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator";
|
|
|
|
|
|
function buildAddressQuery(
|
|
|
addr: QuoteRequest["pickup"],
|
|
|
): string {
|
|
|
const fromFormatted = addr.formattedAddress?.trim();
|
|
|
if (fromFormatted) {
|
|
|
return fromFormatted;
|
|
|
}
|
|
|
return [addr.street, addr.city, addr.state, addr.zip]
|
|
|
.map((part) => part?.trim())
|
|
|
.filter(Boolean)
|
|
|
.join(", ");
|
|
|
}
|
|
|
|
|
|
function resolvePlaceId(addr: QuoteRequest["pickup"]): string | null {
|
|
|
const optionId = addr.mothershipOptionId?.trim();
|
|
|
if (optionId && !optionId.startsWith("ms_")) {
|
|
|
return optionId;
|
|
|
}
|
|
|
const placeId = addr.placeId?.trim();
|
|
|
if (placeId && placeId.startsWith("ChIJ")) {
|
|
|
return placeId;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** embed-demo / RPA Worker:按用户确认的 placeId 直连 axel/quote */
|
|
|
export async function fetchAxelQuoteItems(req: QuoteRequest): Promise<QuoteItem[]> {
|
|
|
const client = await AxelHttpClient.create();
|
|
|
|
|
|
type ResolvedSide = {
|
|
|
placeId: string;
|
|
|
description: string;
|
|
|
place?: AxelPlaceLocation;
|
|
|
};
|
|
|
|
|
|
async function resolveSide(
|
|
|
addr: QuoteRequest["pickup"],
|
|
|
sideLabel: string,
|
|
|
): Promise<ResolvedSide> {
|
|
|
const knownPlaceId = resolvePlaceId(addr);
|
|
|
const description =
|
|
|
addr.mothershipDisplayLabel?.trim() ||
|
|
|
addr.formattedAddress?.trim() ||
|
|
|
buildAddressQuery(addr);
|
|
|
|
|
|
if (knownPlaceId) {
|
|
|
try {
|
|
|
const place = await client.fetchPlace(knownPlaceId);
|
|
|
return { placeId: knownPlaceId, description, place };
|
|
|
} catch {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`${sideLabel}地址在 MotherShip 无法定位,请重新选择候选`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const query = buildAddressQuery(addr);
|
|
|
const candidates = await client.searchLocation(query);
|
|
|
const picked = pickBestSearchCandidate(query, candidates);
|
|
|
if (!picked?.placeId) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`未找到${sideLabel}地址候选:${query}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const place = await client.fetchPlace(picked.placeId);
|
|
|
return {
|
|
|
placeId: picked.placeId,
|
|
|
description: picked.description,
|
|
|
place,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const [pickupSide, deliverySide] = await Promise.all([
|
|
|
resolveSide(req.pickup, "提货"),
|
|
|
resolveSide(req.delivery, "派送"),
|
|
|
]);
|
|
|
|
|
|
const pickup = pickupSide.place ?? await client.fetchPlace(pickupSide.placeId);
|
|
|
const delivery =
|
|
|
deliverySide.place ?? await client.fetchPlace(deliverySide.placeId);
|
|
|
const shipment = buildAxelShipmentPayload({
|
|
|
pickup,
|
|
|
delivery,
|
|
|
pickupDescription: pickupSide.description,
|
|
|
deliveryDescription: deliverySide.description,
|
|
|
cargo: {
|
|
|
palletCount: req.palletCount,
|
|
|
weightLb: req.weightLb,
|
|
|
lengthIn: req.dimsIn.l,
|
|
|
widthIn: req.dimsIn.w,
|
|
|
heightIn: req.dimsIn.h,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
let body: unknown;
|
|
|
try {
|
|
|
body = await client.postQuote(shipment);
|
|
|
} catch (err) {
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
if (
|
|
|
/unable to find any rates|no carriers available|no capacity/i.test(msg) ||
|
|
|
/axel\/quote HTTP 400/.test(msg)
|
|
|
) {
|
|
|
throw new RpaError(
|
|
|
"CARRIER_NO_CAPACITY",
|
|
|
"MotherShip 该线路暂无可用报价,请调整地址或货物后重试",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
throw new RpaError("RPA_DATA_INVALID", msg, { retryable: true });
|
|
|
}
|
|
|
const decoded = decodeAxelQuoteBodyLenient(body);
|
|
|
if (!decoded?.length) {
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"axel/quote 响应无法解析为报价档位",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
console.log(
|
|
|
`[rpa] direct axel quote: pickup=${normalizeAxelPlaceId(pickupSide.placeId)} delivery=${normalizeAxelPlaceId(deliverySide.placeId)} tiers=${decoded.length}`,
|
|
|
);
|
|
|
|
|
|
return normalizeQuoteItems(decoded);
|
|
|
}
|