|
|
import { CARGO_LIMITS } from "@/lib/constants/cargo-limits";
|
|
|
import { snapMothershipReadyDateToWeekday } from "@/lib/mothership/logged-in-constraints";
|
|
|
import { z } from "zod";
|
|
|
import {
|
|
|
buildCargoHash,
|
|
|
buildLoggedInCargoHashExtras,
|
|
|
serializeAddress,
|
|
|
} from "@/modules/quote/cargo-hash";
|
|
|
import type {
|
|
|
NormalizedCargo,
|
|
|
QuoteRequestBody,
|
|
|
ValidationError,
|
|
|
} from "@/modules/quote/types";
|
|
|
import {
|
|
|
SecurityValidationError,
|
|
|
ValidationError as ValidationErrorClass,
|
|
|
} from "@/modules/quote/types";
|
|
|
import {
|
|
|
dimValueToIn,
|
|
|
normalizeDimUnit,
|
|
|
normalizeWeightUnit,
|
|
|
toAxelWholeInches,
|
|
|
toAxelWholePounds,
|
|
|
weightUnitAmbiguousMessage,
|
|
|
weightValueToLb,
|
|
|
} from "@/modules/quote/unit-converter";
|
|
|
|
|
|
/** 查价货物硬限(PRD §4.2.8) */
|
|
|
export const MOTHERSHIP_LIMITS = CARGO_LIMITS;
|
|
|
|
|
|
export const CARGO_TYPES = [
|
|
|
"general_freight",
|
|
|
"machinery",
|
|
|
"furniture",
|
|
|
"electronics",
|
|
|
"building_materials",
|
|
|
"auto_parts",
|
|
|
"food_nonperishable",
|
|
|
"apparel",
|
|
|
"other",
|
|
|
] as const;
|
|
|
|
|
|
const UUID_V4_REGEX =
|
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
|
|
const US_ZIP_REGEX = /^\d{5}(-\d{4})?$/;
|
|
|
|
|
|
const SQL_INJECTION_PATTERNS = [
|
|
|
/(\bunion\b.*\bselect\b)/i,
|
|
|
/(\bdrop\b.*\btable\b)/i,
|
|
|
/(\binsert\b.*\binto\b)/i,
|
|
|
/(\bdelete\b.*\bfrom\b)/i,
|
|
|
/(--|\/\*|\*\/)/,
|
|
|
/(\bor\b\s+\d+\s*=\s*\d+)/i,
|
|
|
/(<script|javascript:)/i,
|
|
|
/(\bexec\b|\bxp_)/i,
|
|
|
];
|
|
|
|
|
|
const addressSchema = z.object({
|
|
|
street: z.string().min(1, "请填写街道地址"),
|
|
|
city: z.string().min(1, "请填写城市"),
|
|
|
state: z.string().min(2, "请填写州/省").max(2, "请填写州/省"),
|
|
|
zip: z
|
|
|
.string()
|
|
|
.refine((v) => v === "" || US_ZIP_REGEX.test(v), "邮编格式无效")
|
|
|
.default(""),
|
|
|
place_id: z.string().min(1, "请从地址列表中选择有效地址"),
|
|
|
formatted_address: z.string().min(1, "请填写完整地址"),
|
|
|
selected_from_suggestions: z.literal(true, {
|
|
|
errorMap: () => ({ message: "请从地址列表中选择有效地址" }),
|
|
|
}),
|
|
|
mothership_option_id: z.string().min(1, "请确认 MotherShip 精确地址"),
|
|
|
mothership_display_label: z.string().min(1, "请确认 MotherShip 精确地址"),
|
|
|
selected_from_mothership: z.literal(true, {
|
|
|
errorMap: () => ({
|
|
|
message: "提货/派送地址须在 MotherShip 候选列表中确认后再询价",
|
|
|
}),
|
|
|
}),
|
|
|
});
|
|
|
|
|
|
const weightUnitSchema = z
|
|
|
.string({ errorMap: () => ({ message: "请选择有效的重量单位" }) })
|
|
|
.superRefine((s, ctx) => {
|
|
|
const ambiguous = weightUnitAmbiguousMessage(s);
|
|
|
if (ambiguous) {
|
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: ambiguous });
|
|
|
return;
|
|
|
}
|
|
|
if (normalizeWeightUnit(s) === null) {
|
|
|
ctx.addIssue({
|
|
|
code: z.ZodIssueCode.custom,
|
|
|
message: "请选择有效的重量单位(lb / kg / t)",
|
|
|
});
|
|
|
}
|
|
|
})
|
|
|
.transform((s) => normalizeWeightUnit(s)!);
|
|
|
|
|
|
const dimUnitSchema = z
|
|
|
.string({ errorMap: () => ({ message: "请选择有效的尺寸单位" }) })
|
|
|
.refine((s) => normalizeDimUnit(s) !== null, "请选择有效的尺寸单位(in / cm / m)")
|
|
|
.transform((s) => normalizeDimUnit(s)!);
|
|
|
|
|
|
const positiveFinite = (label: string) =>
|
|
|
z
|
|
|
.number({ invalid_type_error: `请填写${label}` })
|
|
|
.finite(`请填写有效的${label}`)
|
|
|
.positive(`请填写有效的${label}`);
|
|
|
|
|
|
const weightSchema = z.object({
|
|
|
value: positiveFinite("重量"),
|
|
|
unit: weightUnitSchema,
|
|
|
});
|
|
|
|
|
|
const dimensionsSchema = z.object({
|
|
|
length: positiveFinite("长度"),
|
|
|
width: positiveFinite("宽度"),
|
|
|
height: positiveFinite("高度"),
|
|
|
unit: dimUnitSchema,
|
|
|
});
|
|
|
|
|
|
const quoteBodySchema = z.object({
|
|
|
request_id: z.string().min(1, "请填写请求标识"),
|
|
|
customer_id: z.string().min(1, "请填写客户标识"),
|
|
|
pickup_address: addressSchema,
|
|
|
delivery_address: addressSchema,
|
|
|
weight: weightSchema,
|
|
|
dimensions: dimensionsSchema,
|
|
|
pallet_count: z.number({ invalid_type_error: "请填写托盘数" }),
|
|
|
cargo_type: z.enum(CARGO_TYPES, {
|
|
|
errorMap: () => ({ message: "请选择有效的货物类型" }),
|
|
|
}),
|
|
|
service_level: z.enum(["standard", "guaranteed"]).optional(),
|
|
|
rate_option: z.enum(["lowest", "fastest"]).optional(),
|
|
|
display_unit: z.enum(["imperial", "metric"]).optional(),
|
|
|
quote_session_id: z.string().uuid("会话标识无效").optional(),
|
|
|
pickup_accessorials: z.array(z.string().min(1).max(64)).max(20).optional(),
|
|
|
delivery_accessorials: z.array(z.string().min(1).max(64)).max(20).optional(),
|
|
|
ready_date: z
|
|
|
.string()
|
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/, "可提货日期格式无效")
|
|
|
.transform((v) => snapMothershipReadyDateToWeekday(v))
|
|
|
.optional(),
|
|
|
ready_time: z.string().min(1).max(32).optional(),
|
|
|
/** 多行货物字段已为英制(weight_lb / *_in),不做公制换算 */
|
|
|
cargo_lines: z
|
|
|
.array(
|
|
|
z.object({
|
|
|
cargo_type: z.string().min(1).max(64),
|
|
|
quantity: z.number().finite().positive().max(999),
|
|
|
weight_lb: z.number().finite().positive().max(100_000),
|
|
|
length_in: z.number().finite().positive().max(1_000),
|
|
|
width_in: z.number().finite().positive().max(1_000),
|
|
|
height_in: z.number().finite().positive().max(1_000),
|
|
|
}),
|
|
|
)
|
|
|
.min(1)
|
|
|
.max(20)
|
|
|
.optional(),
|
|
|
mothership_details: z
|
|
|
.object({
|
|
|
pickup: z
|
|
|
.object({
|
|
|
company_name: z.string().max(128).optional(),
|
|
|
suite: z.string().max(64).optional(),
|
|
|
contact_first: z.string().max(64).optional(),
|
|
|
contact_last: z.string().max(64).optional(),
|
|
|
contact_email: z.string().max(128).optional(),
|
|
|
contact_phone: z.string().max(64).optional(),
|
|
|
reference: z.string().max(128).optional(),
|
|
|
notes: z.string().max(1000).optional(),
|
|
|
opens_at: z.string().max(32).optional(),
|
|
|
closes_at: z.string().max(32).optional(),
|
|
|
})
|
|
|
.optional(),
|
|
|
delivery: z
|
|
|
.object({
|
|
|
company_name: z.string().max(128).optional(),
|
|
|
suite: z.string().max(64).optional(),
|
|
|
contact_first: z.string().max(64).optional(),
|
|
|
contact_last: z.string().max(64).optional(),
|
|
|
contact_email: z.string().max(128).optional(),
|
|
|
contact_phone: z.string().max(64).optional(),
|
|
|
reference: z.string().max(128).optional(),
|
|
|
notes: z.string().max(1000).optional(),
|
|
|
opens_at: z.string().max(32).optional(),
|
|
|
closes_at: z.string().max(32).optional(),
|
|
|
})
|
|
|
.optional(),
|
|
|
request_delivery_appointment: z.boolean().optional(),
|
|
|
fba_number: z.string().max(128).optional(),
|
|
|
fba_po_number: z.string().max(128).optional(),
|
|
|
cargo: z
|
|
|
.array(
|
|
|
z.object({
|
|
|
piece_count_type: z.string().max(32).optional(),
|
|
|
piece_count_qty: z.number().min(0).max(999999).optional(),
|
|
|
description: z.string().max(500).optional(),
|
|
|
nmfc: z.string().max(64).optional(),
|
|
|
hazmat: z.boolean().optional(),
|
|
|
alcohol: z.boolean().optional(),
|
|
|
tobacco: z.boolean().optional(),
|
|
|
}),
|
|
|
)
|
|
|
.max(20)
|
|
|
.optional(),
|
|
|
})
|
|
|
.optional(),
|
|
|
});
|
|
|
|
|
|
function containsSqlInjection(value: string): boolean {
|
|
|
return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(value));
|
|
|
}
|
|
|
|
|
|
function checkAddressInjection(
|
|
|
addr: {
|
|
|
street: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
zip: string;
|
|
|
place_id: string;
|
|
|
formatted_address: string;
|
|
|
},
|
|
|
label: string,
|
|
|
): void {
|
|
|
const fields = [
|
|
|
addr.street,
|
|
|
addr.city,
|
|
|
addr.state,
|
|
|
addr.zip,
|
|
|
addr.formatted_address,
|
|
|
addr.place_id,
|
|
|
];
|
|
|
if (fields.some(containsSqlInjection)) {
|
|
|
throw new SecurityValidationError(`${label}无效,请检查`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function validateZip(zip: string, label: string): void {
|
|
|
if (zip.trim() === "") {
|
|
|
return;
|
|
|
}
|
|
|
if (!US_ZIP_REGEX.test(zip)) {
|
|
|
throw new ValidationErrorClass(`${label}邮编无效`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function validateMothershipLimits(
|
|
|
weightLb: number,
|
|
|
dimLIn: number,
|
|
|
dimWIn: number,
|
|
|
dimHIn: number,
|
|
|
palletCount: number,
|
|
|
): void {
|
|
|
const { palletCount: pc, dimIn, weightLbPerPallet, totalWeightLbMax } =
|
|
|
MOTHERSHIP_LIMITS;
|
|
|
|
|
|
if (
|
|
|
!Number.isInteger(palletCount) ||
|
|
|
palletCount < pc.min ||
|
|
|
palletCount > pc.max
|
|
|
) {
|
|
|
throw new ValidationErrorClass(
|
|
|
`托盘数超出 Mothership 允许范围(${pc.min}–${pc.max})`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (weightLb < weightLbPerPallet.min || weightLb > weightLbPerPallet.max) {
|
|
|
throw new ValidationErrorClass("重量超出 Mothership 允许范围");
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
dimLIn < dimIn.min ||
|
|
|
dimLIn > dimIn.lengthMax ||
|
|
|
dimWIn < dimIn.min ||
|
|
|
dimWIn > dimIn.widthMax ||
|
|
|
dimHIn < dimIn.min ||
|
|
|
dimHIn > dimIn.heightMax
|
|
|
) {
|
|
|
throw new ValidationErrorClass("尺寸超出 Mothership 允许范围");
|
|
|
}
|
|
|
|
|
|
const totalWeight = weightLb * palletCount;
|
|
|
if (totalWeight > totalWeightLbMax) {
|
|
|
throw new ValidationErrorClass(
|
|
|
`整票总重超出 Mothership 允许范围(≤${totalWeightLbMax} lb)`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** E4.7:拒绝 quantity 件数字段 */
|
|
|
export function rejectQuantityField(body: Record<string, unknown>): void {
|
|
|
if ("quantity" in body) {
|
|
|
throw new ValidationErrorClass("请使用托盘数 pallet_count");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 校验 + 单位换算 + cargo_hash 生成
|
|
|
* @throws ValidationError
|
|
|
*/
|
|
|
export function validateQuoteInput(
|
|
|
body: unknown,
|
|
|
): NormalizedCargo {
|
|
|
if (typeof body !== "object" || body === null) {
|
|
|
throw new ValidationErrorClass("请求体无效");
|
|
|
}
|
|
|
|
|
|
rejectQuantityField(body as Record<string, unknown>);
|
|
|
|
|
|
const parsed = quoteBodySchema.safeParse(body);
|
|
|
if (!parsed.success) {
|
|
|
const first = parsed.error.errors[0];
|
|
|
throw new ValidationErrorClass(first?.message ?? "请求参数无效");
|
|
|
}
|
|
|
|
|
|
const data = parsed.data as QuoteRequestBody;
|
|
|
|
|
|
if (!UUID_V4_REGEX.test(data.request_id)) {
|
|
|
throw new ValidationErrorClass("请求标识无效");
|
|
|
}
|
|
|
|
|
|
checkAddressInjection(data.pickup_address, "发货地址");
|
|
|
checkAddressInjection(data.delivery_address, "收货地址");
|
|
|
validateZip(data.pickup_address.zip, "发货地址");
|
|
|
validateZip(data.delivery_address.zip, "收货地址");
|
|
|
|
|
|
const weightLbRaw = weightValueToLb(data.weight.value, data.weight.unit);
|
|
|
const dimLInRaw = dimValueToIn(data.dimensions.length, data.dimensions.unit);
|
|
|
const dimWInRaw = dimValueToIn(data.dimensions.width, data.dimensions.unit);
|
|
|
const dimHInRaw = dimValueToIn(data.dimensions.height, data.dimensions.unit);
|
|
|
|
|
|
validateMothershipLimits(
|
|
|
weightLbRaw,
|
|
|
dimLInRaw,
|
|
|
dimWInRaw,
|
|
|
dimHInRaw,
|
|
|
data.pallet_count,
|
|
|
);
|
|
|
|
|
|
const weightLb = toAxelWholePounds(weightLbRaw);
|
|
|
const dimLIn = toAxelWholeInches(dimLInRaw);
|
|
|
const dimWIn = toAxelWholeInches(dimWInRaw);
|
|
|
const dimHIn = toAxelWholeInches(dimHInRaw);
|
|
|
|
|
|
const pickupSerialized = serializeAddress(data.pickup_address);
|
|
|
const deliverySerialized = serializeAddress(data.delivery_address);
|
|
|
|
|
|
const cargoHash = buildCargoHash({
|
|
|
pickupSerialized,
|
|
|
deliverySerialized,
|
|
|
weightLb,
|
|
|
dimLIn,
|
|
|
dimWIn,
|
|
|
dimHIn,
|
|
|
palletCount: data.pallet_count,
|
|
|
cargoType: data.cargo_type,
|
|
|
loggedInExtras: buildLoggedInCargoHashExtras({
|
|
|
pickupAccessorials: data.pickup_accessorials,
|
|
|
deliveryAccessorials: data.delivery_accessorials,
|
|
|
readyDate: data.ready_date,
|
|
|
readyTime: data.ready_time,
|
|
|
cargoLines: data.cargo_lines?.map((row) => ({
|
|
|
cargoType: row.cargo_type,
|
|
|
quantity: row.quantity,
|
|
|
weightLb: row.weight_lb,
|
|
|
lengthIn: row.length_in,
|
|
|
widthIn: row.width_in,
|
|
|
heightIn: row.height_in,
|
|
|
})),
|
|
|
}),
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
requestId: data.request_id,
|
|
|
customerId: data.customer_id,
|
|
|
pickupAddress: data.pickup_address,
|
|
|
deliveryAddress: data.delivery_address,
|
|
|
weightLb,
|
|
|
dimLIn,
|
|
|
dimWIn,
|
|
|
dimHIn,
|
|
|
palletCount: data.pallet_count,
|
|
|
cargoType: data.cargo_type,
|
|
|
serviceLevel: data.service_level ?? "standard",
|
|
|
rateOption: data.rate_option ?? "lowest",
|
|
|
cargoHash,
|
|
|
pickupSerialized,
|
|
|
deliverySerialized,
|
|
|
quoteSessionId: data.quote_session_id,
|
|
|
pickupAccessorials: data.pickup_accessorials,
|
|
|
deliveryAccessorials: data.delivery_accessorials,
|
|
|
readyDate: data.ready_date,
|
|
|
readyTime: data.ready_time,
|
|
|
cargoLines: data.cargo_lines?.map((row) => ({
|
|
|
cargoType: row.cargo_type,
|
|
|
quantity: row.quantity,
|
|
|
weightLb: row.weight_lb,
|
|
|
lengthIn: row.length_in,
|
|
|
widthIn: row.width_in,
|
|
|
heightIn: row.height_in,
|
|
|
})),
|
|
|
mothershipDetails: data.mothership_details,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export type { ValidationError };
|