|
|
/**
|
|
|
* Flock 结账选档校验 + 档内灵活性价 / Standard 承运商列表
|
|
|
*/
|
|
|
|
|
|
export type FlockCheckoutTier = "flock_direct" | "standard";
|
|
|
|
|
|
/** 官网 See Pricing Options 后三档(FlockDirect) */
|
|
|
export type FlockFlexibilityKey = "2_day" | "1_day" | "none";
|
|
|
|
|
|
/** 报价形态:灵活价 vs Standard LTL 承运商列表 */
|
|
|
export type FlockPricingOptionsType = "flexibility" | "carriers";
|
|
|
|
|
|
export type FlockCheckoutSelection = {
|
|
|
preferredTier: FlockCheckoutTier;
|
|
|
preferredFlexibility?: FlockFlexibilityKey | null;
|
|
|
/** Standard LTL 承运商名(与 preferredFlexibility 二选一) */
|
|
|
preferredCarrier?: string | null;
|
|
|
};
|
|
|
|
|
|
export type FlockFlexibilityOption = {
|
|
|
key: FlockFlexibilityKey;
|
|
|
/** 行内可见文案(flexibility / pickup / deliver / rate) */
|
|
|
label: string;
|
|
|
rateUsd: number;
|
|
|
pickupWindow?: string;
|
|
|
deliverBy?: string;
|
|
|
};
|
|
|
|
|
|
/** Standard LTL:See Pricing 后承运商行 */
|
|
|
export type FlockCarrierOption = {
|
|
|
carrierName: string;
|
|
|
transitDays: string;
|
|
|
pickupDate?: string;
|
|
|
deliveryEstimate?: string;
|
|
|
rateUsd: number;
|
|
|
label: string;
|
|
|
};
|
|
|
|
|
|
/** 单档刮取结果(灵活价或承运商) */
|
|
|
export type FlockTierPricingScrape = {
|
|
|
options_type: FlockPricingOptionsType;
|
|
|
options: FlockFlexibilityOption[];
|
|
|
carrier_options: FlockCarrierOption[];
|
|
|
};
|
|
|
|
|
|
export function parseFlockFlexibilityKey(
|
|
|
label: string,
|
|
|
): FlockFlexibilityKey | null {
|
|
|
const t = label.replace(/\s+/g, " ").toLowerCase();
|
|
|
if (/2[-\s]?day\s+flexibility/.test(t)) return "2_day";
|
|
|
if (/1[-\s]?day\s+flexibility/.test(t)) return "1_day";
|
|
|
if (/no\s+flexibility/.test(t)) return "none";
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
export function flockFlexibilityKeyLabel(key: FlockFlexibilityKey): string {
|
|
|
switch (key) {
|
|
|
case "2_day":
|
|
|
return "2 天灵活性";
|
|
|
case "1_day":
|
|
|
return "1 天灵活性";
|
|
|
case "none":
|
|
|
return "无灵活性";
|
|
|
default:
|
|
|
return key;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 返回中文错误;null = 通过 */
|
|
|
export function validateFlockCheckoutSelection(
|
|
|
input: FlockCheckoutSelection,
|
|
|
): string | null {
|
|
|
const tier = input.preferredTier;
|
|
|
if (tier !== "flock_direct" && tier !== "standard") {
|
|
|
return "请选择 FlockDirect® 或 Standard 报价档";
|
|
|
}
|
|
|
const carrier = input.preferredCarrier?.trim() || "";
|
|
|
const hasFlex =
|
|
|
input.preferredFlexibility === "2_day" ||
|
|
|
input.preferredFlexibility === "1_day" ||
|
|
|
input.preferredFlexibility === "none";
|
|
|
if (carrier && hasFlex) {
|
|
|
return "灵活性档与承运商不能同时选择";
|
|
|
}
|
|
|
if (carrier) {
|
|
|
if (carrier.length > 128) return "承运商名称过长";
|
|
|
return null;
|
|
|
}
|
|
|
if (!hasFlex) {
|
|
|
return "请选择灵活性报价档或承运商";
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 承运商列表选最低价;同价取首项;空列表返回 -1 */
|
|
|
export function pickLowestCarrierOptionIndex(
|
|
|
options: FlockCarrierOption[],
|
|
|
): number {
|
|
|
if (options.length === 0) return -1;
|
|
|
let best = 0;
|
|
|
for (let i = 1; i < options.length; i += 1) {
|
|
|
if (options[i]!.rateUsd < options[best]!.rateUsd) {
|
|
|
best = i;
|
|
|
}
|
|
|
}
|
|
|
return best;
|
|
|
}
|
|
|
|
|
|
/** 按承运商名选;找不到回落最低价 */
|
|
|
export function pickCarrierOptionIndex(
|
|
|
options: FlockCarrierOption[],
|
|
|
carrierName: string | null | undefined,
|
|
|
): number {
|
|
|
const want = carrierName?.trim().toLowerCase();
|
|
|
if (!want) return pickLowestCarrierOptionIndex(options);
|
|
|
const idx = options.findIndex(
|
|
|
(o) => o.carrierName.trim().toLowerCase() === want,
|
|
|
);
|
|
|
return idx >= 0 ? idx : pickLowestCarrierOptionIndex(options);
|
|
|
}
|
|
|
|
|
|
/** 从官网承运商行文案提炼字段 */
|
|
|
export function enrichCarrierOption(raw: {
|
|
|
label: string;
|
|
|
rateUsd: number;
|
|
|
}): FlockCarrierOption | null {
|
|
|
const text = raw.label.replace(/\s+/g, " ").trim();
|
|
|
if (!text || /Save on average|Don't ship air|Quote shipment again/i.test(text)) {
|
|
|
return null;
|
|
|
}
|
|
|
const transit =
|
|
|
text.match(/(\d+(?:\s*[–\-]\s*\d+)?)\s*business\s*days?/i)?.[0]?.trim() ||
|
|
|
text.match(/(\d+)\s*days?/i)?.[0]?.trim() ||
|
|
|
"";
|
|
|
const dateRange =
|
|
|
text.match(
|
|
|
/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}(?:\s*[–\-]\s*(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+)?\d{1,2})?)/i,
|
|
|
)?.[1]?.trim() || undefined;
|
|
|
let pickupDate: string | undefined;
|
|
|
let deliveryEstimate: string | undefined;
|
|
|
if (dateRange) {
|
|
|
const parts = dateRange.split(/\s*[–\-]\s*/);
|
|
|
pickupDate = parts[0]?.trim();
|
|
|
deliveryEstimate = parts[1]?.trim() || parts[0]?.trim();
|
|
|
}
|
|
|
// 承运商名:去掉 Select / 价格 / 时效 / 日期 / 表头噪声后的剩余前缀
|
|
|
let carrierName = text
|
|
|
.replace(/Select\s*→?/gi, "")
|
|
|
.replace(/\$\s*[\d,]+(?:\.\d{1,2})?/g, "")
|
|
|
.replace(/\d+(?:\s*[–\-]\s*\d+)?\s*business\s*days?/gi, "")
|
|
|
.replace(
|
|
|
/(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}(?:\s*[–\-]\s*(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+)?\d{1,2})?/gi,
|
|
|
"",
|
|
|
)
|
|
|
.replace(
|
|
|
/\b(Carrier|Transit|from carrier|Pickup|Delivery|estimate|Rate|Select your preferred LTL carrier|Show predictive)\b/gi,
|
|
|
"",
|
|
|
)
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (!carrierName || carrierName.length < 2) return null;
|
|
|
if (carrierName.length > 80) carrierName = carrierName.slice(0, 80).trim();
|
|
|
// 行级祖先偶发吞整表:取最后一个像样的专名片段
|
|
|
if (/Inc\.?|LLC|Systems|Freight|Air|Lines/i.test(carrierName) && carrierName.length > 40) {
|
|
|
const parts = carrierName.split(/\s{2,}|\s*\|\s*/).map((p) => p.trim()).filter(Boolean);
|
|
|
const last = parts[parts.length - 1];
|
|
|
if (last && last.length >= 3 && last.length <= 60) carrierName = last;
|
|
|
}
|
|
|
return {
|
|
|
carrierName,
|
|
|
transitDays: transit || "—",
|
|
|
pickupDate,
|
|
|
deliveryEstimate,
|
|
|
rateUsd: raw.rateUsd,
|
|
|
label: text.slice(0, 200),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/** 档内选最低价;同价取首项;空列表返回 -1 */
|
|
|
export function pickLowestRateOptionIndex(
|
|
|
options: FlockFlexibilityOption[],
|
|
|
): number {
|
|
|
if (options.length === 0) return -1;
|
|
|
let best = 0;
|
|
|
for (let i = 1; i < options.length; i += 1) {
|
|
|
if (options[i]!.rateUsd < options[best]!.rateUsd) {
|
|
|
best = i;
|
|
|
}
|
|
|
}
|
|
|
return best;
|
|
|
}
|
|
|
|
|
|
/** 按 key 选档;找不到返回 -1 */
|
|
|
export function pickFlexibilityOptionIndex(
|
|
|
options: FlockFlexibilityOption[],
|
|
|
key: FlockFlexibilityKey | null | undefined,
|
|
|
): number {
|
|
|
if (!key) return pickLowestRateOptionIndex(options);
|
|
|
const idx = options.findIndex((o) => o.key === key);
|
|
|
return idx >= 0 ? idx : pickLowestRateOptionIndex(options);
|
|
|
}
|
|
|
|
|
|
export function parseFlockRateUsd(text: string): number | null {
|
|
|
const m = text.replace(/,/g, "").match(/\$\s*([\d]+(?:\.\d{1,2})?)/);
|
|
|
if (!m) return null;
|
|
|
const n = Number(m[1]);
|
|
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
|
}
|
|
|
|
|
|
/** 从官网行文案提炼 pickup / deliver */
|
|
|
export function parseFlexibilityWindows(label: string): {
|
|
|
pickupWindow?: string;
|
|
|
deliverBy?: string;
|
|
|
} {
|
|
|
const pickup =
|
|
|
label.match(/Pickup\s+([A-Za-z0-9–\-\s,]+?)(?:\s+Deliver|$)/i)?.[1]?.trim() ||
|
|
|
undefined;
|
|
|
const deliverBy =
|
|
|
label.match(/Deliver\s+by\s+([A-Za-z0-9–\-\s,]+?)(?:\s+\$|$)/i)?.[1]?.trim() ||
|
|
|
undefined;
|
|
|
return { pickupWindow: pickup, deliverBy };
|
|
|
}
|
|
|
|
|
|
export function enrichFlexibilityOption(
|
|
|
raw: { label: string; rateUsd: number },
|
|
|
): FlockFlexibilityOption | null {
|
|
|
const key = parseFlockFlexibilityKey(raw.label);
|
|
|
if (!key) return null;
|
|
|
const windows = parseFlexibilityWindows(raw.label);
|
|
|
return {
|
|
|
key,
|
|
|
label: raw.label,
|
|
|
rateUsd: raw.rateUsd,
|
|
|
...windows,
|
|
|
};
|
|
|
}
|