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; 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 { if (!body || typeof body !== "object") { throw new ValidationError("请求体格式无效"); } return body as Record; } 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; 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) : 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) : 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, ), }; }