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.

382 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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"
? "货物数量须为 1999 的整数"
: 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,
),
};
}