|
|
import {
|
|
|
FLOCK_LIMITS,
|
|
|
FLOCK_VALIDATION_MESSAGES,
|
|
|
fitsFlockTrailerCargo,
|
|
|
} from "@/lib/constants/flock-limits";
|
|
|
import {
|
|
|
dimValueToIn,
|
|
|
normalizeDimUnit,
|
|
|
normalizeWeightUnit,
|
|
|
toAxelWholeInches,
|
|
|
toAxelWholePounds,
|
|
|
weightUnitAmbiguousMessage,
|
|
|
weightValueToLb,
|
|
|
} from "@/modules/quote/unit-converter";
|
|
|
import {
|
|
|
defaultFlockPickupDateIso,
|
|
|
flockPickupDateValidationMessageFromDisplay,
|
|
|
flockPickupDisplayFromIso,
|
|
|
} from "@/lib/flock/pickup-date";
|
|
|
import { ValidationError } from "@/modules/quote/types";
|
|
|
import type {
|
|
|
FlockQuoteInput,
|
|
|
FlockRegistrationOverride,
|
|
|
} from "@/workers/rpa/flock/types";
|
|
|
|
|
|
const DATE_RE = /^\d{2}\/\d{2}\/\d{4}$/;
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
const SHIPMENTS_OPTIONS = new Set(["1-25", "26-50", "51-100", "101+"]);
|
|
|
|
|
|
function optionalTrim(value: unknown): string | undefined {
|
|
|
if (typeof value !== "string") {
|
|
|
return undefined;
|
|
|
}
|
|
|
const trimmed = value.trim();
|
|
|
return trimmed || undefined;
|
|
|
}
|
|
|
|
|
|
function parseRegistrationBlock(
|
|
|
raw: unknown,
|
|
|
): FlockRegistrationOverride | undefined {
|
|
|
if (!raw || typeof raw !== "object") {
|
|
|
return undefined;
|
|
|
}
|
|
|
const r = raw as Record<string, unknown>;
|
|
|
const firstName = optionalTrim(r.first_name ?? r.firstName);
|
|
|
const lastName = optionalTrim(r.last_name ?? r.lastName);
|
|
|
const company = optionalTrim(r.company ?? r.company_name ?? r.companyName);
|
|
|
const email = optionalTrim(r.email ?? r.work_email ?? r.workEmail);
|
|
|
const phoneRaw = optionalTrim(r.phone ?? r.phone_number ?? r.phoneNumber);
|
|
|
const shipmentsPerMonth = optionalTrim(
|
|
|
r.shipments_per_month ?? r.shipmentsPerMonth,
|
|
|
);
|
|
|
|
|
|
if (
|
|
|
!firstName &&
|
|
|
!lastName &&
|
|
|
!company &&
|
|
|
!email &&
|
|
|
!phoneRaw &&
|
|
|
!shipmentsPerMonth
|
|
|
) {
|
|
|
return undefined;
|
|
|
}
|
|
|
|
|
|
if (email && !EMAIL_RE.test(email)) {
|
|
|
throw new ValidationError("请填写有效的工作邮箱");
|
|
|
}
|
|
|
|
|
|
let phone: string | undefined;
|
|
|
if (phoneRaw) {
|
|
|
const digits = phoneRaw.replace(/\D/g, "").slice(-10);
|
|
|
if (digits.length !== 10) {
|
|
|
throw new ValidationError("请填写有效的 10 位美国电话号码");
|
|
|
}
|
|
|
phone = digits;
|
|
|
}
|
|
|
|
|
|
if (shipmentsPerMonth && !SHIPMENTS_OPTIONS.has(shipmentsPerMonth)) {
|
|
|
throw new ValidationError("月出货量选项无效");
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
firstName,
|
|
|
lastName,
|
|
|
company,
|
|
|
email,
|
|
|
phone,
|
|
|
shipmentsPerMonth,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function asObject(body: unknown): Record<string, unknown> {
|
|
|
if (!body || typeof body !== "object") {
|
|
|
throw new ValidationError("请求体格式无效");
|
|
|
}
|
|
|
return body as Record<string, unknown>;
|
|
|
}
|
|
|
|
|
|
function reqString(value: unknown, message: string): string {
|
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
|
throw new ValidationError(message);
|
|
|
}
|
|
|
return value.trim();
|
|
|
}
|
|
|
|
|
|
function parsePositiveNumber(value: unknown, label: string): number {
|
|
|
const n =
|
|
|
typeof value === "number"
|
|
|
? value
|
|
|
: typeof value === "string"
|
|
|
? Number(value.trim())
|
|
|
: NaN;
|
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
|
throw new ValidationError(`请填写有效的${label}`);
|
|
|
}
|
|
|
return n;
|
|
|
}
|
|
|
|
|
|
function normalizeZip(raw: string, emptyMessage: string): string {
|
|
|
const zip = raw.trim();
|
|
|
if (!zip) {
|
|
|
throw new ValidationError(emptyMessage);
|
|
|
}
|
|
|
if (!FLOCK_LIMITS.zipPattern.test(zip)) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.zipFormat);
|
|
|
}
|
|
|
return zip.slice(0, 5);
|
|
|
}
|
|
|
|
|
|
/** 默认取货日(下一工作日),供前端预填 */
|
|
|
export function defaultFlockPickupDate(): string {
|
|
|
return flockPickupDisplayFromIso(defaultFlockPickupDateIso());
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 校验 Flock 询价入参;重量/尺寸支持 kg/cm,换算为 lb/in 后小数进一(ceil)
|
|
|
* 例:500 kg → 1102.31 → 1103 lb
|
|
|
*/
|
|
|
export function validateFlockQuoteInput(
|
|
|
body: unknown,
|
|
|
): FlockQuoteInput & { request_id: string; customer_id: string } {
|
|
|
const o = asObject(body);
|
|
|
const requestId = reqString(o.request_id, "请填写请求标识");
|
|
|
const customerId = reqString(o.customer_id, "请填写客户标识");
|
|
|
const rawInput = o.flock_input;
|
|
|
if (!rawInput || typeof rawInput !== "object") {
|
|
|
throw new ValidationError("请填写 Flock 表单参数");
|
|
|
}
|
|
|
const f = rawInput as Record<string, unknown>;
|
|
|
|
|
|
const pickupDate = reqString(
|
|
|
f.pickup_date ?? f.pickupDate,
|
|
|
FLOCK_VALIDATION_MESSAGES.pickupDate,
|
|
|
);
|
|
|
if (!DATE_RE.test(pickupDate)) {
|
|
|
throw new ValidationError("取货日期格式须为 MM/DD/YYYY");
|
|
|
}
|
|
|
const pickupDateError =
|
|
|
flockPickupDateValidationMessageFromDisplay(pickupDate);
|
|
|
if (pickupDateError) {
|
|
|
throw new ValidationError(pickupDateError);
|
|
|
}
|
|
|
|
|
|
const pickupZip = normalizeZip(
|
|
|
reqString(
|
|
|
f.pickup_zip ?? f.pickupZip,
|
|
|
FLOCK_VALIDATION_MESSAGES.pickupZip,
|
|
|
),
|
|
|
FLOCK_VALIDATION_MESSAGES.pickupZip,
|
|
|
);
|
|
|
const deliveryZip = normalizeZip(
|
|
|
reqString(
|
|
|
f.delivery_zip ?? f.deliveryZip,
|
|
|
FLOCK_VALIDATION_MESSAGES.deliveryZip,
|
|
|
),
|
|
|
FLOCK_VALIDATION_MESSAGES.deliveryZip,
|
|
|
);
|
|
|
if (pickupZip === deliveryZip) {
|
|
|
throw new ValidationError("提货与派送邮编不能相同");
|
|
|
}
|
|
|
|
|
|
const formModeRaw = f.form_mode ?? f.formMode;
|
|
|
const formMode =
|
|
|
formModeRaw === "logged_in_quick" ? ("logged_in_quick" as const) : undefined;
|
|
|
|
|
|
const palletRaw = f.pallet_count ?? f.palletCount;
|
|
|
const palletCount =
|
|
|
typeof palletRaw === "number"
|
|
|
? palletRaw
|
|
|
: Number(String(palletRaw ?? "").trim());
|
|
|
const qtyMin = formMode === "logged_in_quick" ? 1 : FLOCK_LIMITS.palletCount.min;
|
|
|
const qtyMax =
|
|
|
formMode === "logged_in_quick" ? 999 : FLOCK_LIMITS.palletCount.max;
|
|
|
if (
|
|
|
!Number.isInteger(palletCount) ||
|
|
|
palletCount < qtyMin ||
|
|
|
palletCount > qtyMax
|
|
|
) {
|
|
|
throw new ValidationError(
|
|
|
formMode === "logged_in_quick"
|
|
|
? "货物数量须为 1–999 的整数"
|
|
|
: FLOCK_VALIDATION_MESSAGES.palletCount,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const weightObj =
|
|
|
f.total_weight && typeof f.total_weight === "object"
|
|
|
? (f.total_weight as Record<string, unknown>)
|
|
|
: null;
|
|
|
const weightValue = parsePositiveNumber(
|
|
|
weightObj?.value ?? f.total_weight_lb ?? f.totalWeightLb,
|
|
|
"总运输重量",
|
|
|
);
|
|
|
const weightUnitRaw =
|
|
|
weightObj && typeof weightObj.unit === "string"
|
|
|
? weightObj.unit
|
|
|
: "lb";
|
|
|
const weightAmbiguous = weightUnitAmbiguousMessage(weightUnitRaw);
|
|
|
if (weightAmbiguous) {
|
|
|
throw new ValidationError(weightAmbiguous);
|
|
|
}
|
|
|
const weightUnit = normalizeWeightUnit(weightUnitRaw);
|
|
|
if (!weightUnit) {
|
|
|
throw new ValidationError("重量单位须为 lb、kg 或 t/tonne(公制吨)");
|
|
|
}
|
|
|
const totalWeightLb = toAxelWholePounds(
|
|
|
weightValueToLb(weightValue, weightUnit),
|
|
|
);
|
|
|
if (totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.totalWeight);
|
|
|
}
|
|
|
|
|
|
const dimObj =
|
|
|
f.dimensions && typeof f.dimensions === "object"
|
|
|
? (f.dimensions as Record<string, unknown>)
|
|
|
: null;
|
|
|
const dimUnitRaw =
|
|
|
dimObj && typeof dimObj.unit === "string" ? dimObj.unit : "in";
|
|
|
const dimUnit = normalizeDimUnit(dimUnitRaw);
|
|
|
if (!dimUnit) {
|
|
|
throw new ValidationError("尺寸单位须为 in、cm 或 m");
|
|
|
}
|
|
|
const lengthRaw = parsePositiveNumber(
|
|
|
dimObj?.length ?? f.length_in ?? f.lengthIn ?? FLOCK_LIMITS.defaultDimsIn.length,
|
|
|
"长度",
|
|
|
);
|
|
|
const widthRaw = parsePositiveNumber(
|
|
|
dimObj?.width ?? f.width_in ?? f.widthIn ?? FLOCK_LIMITS.defaultDimsIn.width,
|
|
|
"宽度",
|
|
|
);
|
|
|
const heightRaw = parsePositiveNumber(
|
|
|
dimObj?.height ?? f.height_in ?? f.heightIn ?? FLOCK_LIMITS.defaultDimsIn.height,
|
|
|
"高度",
|
|
|
);
|
|
|
const toIn = (v: number) => toAxelWholeInches(dimValueToIn(v, dimUnit));
|
|
|
const lengthIn = toIn(lengthRaw);
|
|
|
const widthIn = toIn(widthRaw);
|
|
|
const heightIn = toIn(heightRaw);
|
|
|
|
|
|
if (lengthIn > FLOCK_LIMITS.dimIn.lengthMax) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.length);
|
|
|
}
|
|
|
if (widthIn > FLOCK_LIMITS.dimIn.widthMax) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.width);
|
|
|
}
|
|
|
if (heightIn > FLOCK_LIMITS.dimIn.heightMax) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.height);
|
|
|
}
|
|
|
if (
|
|
|
formMode !== "logged_in_quick" &&
|
|
|
!fitsFlockTrailerCargo({ palletCount, lengthIn, widthIn })
|
|
|
) {
|
|
|
throw new ValidationError(FLOCK_VALIDATION_MESSAGES.linearFeet);
|
|
|
}
|
|
|
|
|
|
const registration = parseRegistrationBlock(f.registration ?? f.account);
|
|
|
|
|
|
const optionalStr = (v: unknown): string | undefined => {
|
|
|
if (typeof v !== "string") return undefined;
|
|
|
const t = v.trim();
|
|
|
return t || undefined;
|
|
|
};
|
|
|
|
|
|
const optionalBool = (v: unknown): boolean | undefined =>
|
|
|
typeof v === "boolean" ? v : undefined;
|
|
|
|
|
|
return {
|
|
|
request_id: requestId,
|
|
|
customer_id: customerId,
|
|
|
pickupDate,
|
|
|
pickupZip,
|
|
|
deliveryZip,
|
|
|
palletCount,
|
|
|
totalWeightLb,
|
|
|
lengthIn,
|
|
|
widthIn,
|
|
|
heightIn,
|
|
|
registration,
|
|
|
formMode,
|
|
|
pickupLocationType: optionalStr(
|
|
|
f.pickup_location_type ?? f.pickupLocationType,
|
|
|
),
|
|
|
deliveryLocationType: optionalStr(
|
|
|
f.delivery_location_type ?? f.deliveryLocationType,
|
|
|
),
|
|
|
packagingType: optionalStr(f.packaging_type ?? f.packagingType),
|
|
|
freightClass: optionalStr(f.freight_class ?? f.freightClass),
|
|
|
description: optionalStr(f.description),
|
|
|
stackable:
|
|
|
typeof f.stackable === "boolean" ? f.stackable : undefined,
|
|
|
turnable: typeof f.turnable === "boolean" ? f.turnable : undefined,
|
|
|
additionalServices: Array.isArray(f.additional_services ?? f.additionalServices)
|
|
|
? ((f.additional_services ?? f.additionalServices) as unknown[])
|
|
|
.filter((x): x is string => typeof x === "string" && x.trim().length > 0)
|
|
|
.map((x) => x.trim())
|
|
|
: undefined,
|
|
|
vehicleTypes: Array.isArray(f.vehicle_types ?? f.vehicleTypes)
|
|
|
? ((f.vehicle_types ?? f.vehicleTypes) as unknown[])
|
|
|
.filter((x): x is string => typeof x === "string" && x.trim().length > 0)
|
|
|
.map((x) => x.trim())
|
|
|
: undefined,
|
|
|
callBeforePickup:
|
|
|
typeof f.call_before_pickup === "boolean"
|
|
|
? f.call_before_pickup
|
|
|
: typeof f.callBeforePickup === "boolean"
|
|
|
? f.callBeforePickup
|
|
|
: undefined,
|
|
|
callBeforeDelivery:
|
|
|
typeof f.call_before_delivery === "boolean"
|
|
|
? f.call_before_delivery
|
|
|
: typeof f.callBeforeDelivery === "boolean"
|
|
|
? f.callBeforeDelivery
|
|
|
: undefined,
|
|
|
pickupLiftgate: optionalBool(
|
|
|
f.pickup_liftgate ?? f.pickupLiftgate,
|
|
|
),
|
|
|
pickupInside: optionalBool(f.pickup_inside ?? f.pickupInside),
|
|
|
pickupPalletJack: optionalBool(
|
|
|
f.pickup_pallet_jack ?? f.pickupPalletJack,
|
|
|
),
|
|
|
deliveryLiftgate: optionalBool(
|
|
|
f.delivery_liftgate ?? f.deliveryLiftgate,
|
|
|
),
|
|
|
deliveryInside: optionalBool(f.delivery_inside ?? f.deliveryInside),
|
|
|
deliveryPalletJack: optionalBool(
|
|
|
f.delivery_pallet_jack ?? f.deliveryPalletJack,
|
|
|
),
|
|
|
pickupService: optionalStr(f.pickup_service ?? f.pickupService),
|
|
|
deliveryService: optionalStr(f.delivery_service ?? f.deliveryService),
|
|
|
pickupWindowStartTime: optionalStr(
|
|
|
f.pickup_window_start_time ?? f.pickupWindowStartTime,
|
|
|
),
|
|
|
pickupWindowEndTime: optionalStr(
|
|
|
f.pickup_window_end_time ?? f.pickupWindowEndTime,
|
|
|
),
|
|
|
pickupAppointmentTime: optionalStr(
|
|
|
f.pickup_appointment_time ?? f.pickupAppointmentTime,
|
|
|
),
|
|
|
deliveryWindowStartTime: optionalStr(
|
|
|
f.delivery_window_start_time ?? f.deliveryWindowStartTime,
|
|
|
),
|
|
|
deliveryWindowEndTime: optionalStr(
|
|
|
f.delivery_window_end_time ?? f.deliveryWindowEndTime,
|
|
|
),
|
|
|
deliveryWindowDate: optionalStr(
|
|
|
f.delivery_window_date ?? f.deliveryWindowDate,
|
|
|
),
|
|
|
deliveryAppointmentTime: optionalStr(
|
|
|
f.delivery_appointment_time ?? f.deliveryAppointmentTime,
|
|
|
),
|
|
|
deliveryAppointmentDate: optionalStr(
|
|
|
f.delivery_appointment_date ?? f.deliveryAppointmentDate,
|
|
|
),
|
|
|
deliveryMustArriveByDate: optionalStr(
|
|
|
f.delivery_must_arrive_by_date ?? f.deliveryMustArriveByDate,
|
|
|
),
|
|
|
callForDeliveryAppointment: optionalBool(
|
|
|
f.call_for_delivery_appointment ?? f.callForDeliveryAppointment,
|
|
|
),
|
|
|
};
|
|
|
}
|