|
|
/**
|
|
|
* Priority1 表单填写规则与站点硬限制(RPA / 系统联调 / canonical 用例校验)
|
|
|
* 来源:form-logic-map 探测、批量验收、人工录制与页面校验文案
|
|
|
*/
|
|
|
|
|
|
import type { Priority1DemoInput } from "@/workers/rpa/priority1/demo-input";
|
|
|
import type {
|
|
|
ExpeditedTrailerType,
|
|
|
FtlTrailerType,
|
|
|
Priority1ShipmentType,
|
|
|
} from "@/workers/rpa/priority1/shipment-types";
|
|
|
import { expeditedLiftgateAvailable } from "@/workers/rpa/priority1/shipment-types";
|
|
|
|
|
|
export type Priority1ConstraintSeverity = "error" | "warning";
|
|
|
|
|
|
export type Priority1ConstraintViolation = {
|
|
|
severity: Priority1ConstraintSeverity;
|
|
|
code: string;
|
|
|
message: string;
|
|
|
field?: string;
|
|
|
};
|
|
|
|
|
|
/** 互斥与结构规则(选 A 则不能/不必选 B) */
|
|
|
export const PRIORITY1_MUTEX_RULES: readonly {
|
|
|
id: string;
|
|
|
rule: string;
|
|
|
}[] = [
|
|
|
{
|
|
|
id: "shipment_type_one",
|
|
|
rule: "Step1 运输类型三选一:LTL / FTL / Expedited,不可多选",
|
|
|
},
|
|
|
{
|
|
|
id: "ftl_trailer_one",
|
|
|
rule: "FTL Step2 主拖车四选一:Dry Van / Open Deck / Temperature Controlled / Expedited(内嵌)",
|
|
|
},
|
|
|
{
|
|
|
id: "expedited_trailer_one",
|
|
|
rule: "Expedited 主类型 Step2 拖车三选一:Sprinter / Small Straight / Large Straight",
|
|
|
},
|
|
|
{
|
|
|
id: "ftl_expedited_sub_one",
|
|
|
rule: "FTL 内嵌 Expedited 须再选子车型三选一(同 Expedited 三卡)",
|
|
|
},
|
|
|
{
|
|
|
id: "open_deck_requires_subtype",
|
|
|
rule: "选 Open Deck 后必须选 Open Deck Type(#PickupLocation3),否则无法继续",
|
|
|
},
|
|
|
{
|
|
|
id: "temp_requires_range",
|
|
|
rule: "选 Temperature Controlled 后必须填最低/最高温度(°F)",
|
|
|
},
|
|
|
{
|
|
|
id: "liftgate_large_only",
|
|
|
rule: "Liftgate 仅在选择 Large Straight Box Truck 时出现且有意义(Expedited 主类型或 FTL 内嵌 Expedited)",
|
|
|
},
|
|
|
{
|
|
|
id: "no_pallets_expedited",
|
|
|
rule: "Expedited 主类型与 FTL 内嵌 Expedited 无 Number of Pallets 字段",
|
|
|
},
|
|
|
{
|
|
|
id: "ftl_non_expedited_has_pallets",
|
|
|
rule: "FTL Dry Van / Open Deck / Temperature 须填托盘数",
|
|
|
},
|
|
|
{
|
|
|
id: "zip_distinct",
|
|
|
rule: "起运 ZIP 与目的 ZIP 不能相同",
|
|
|
},
|
|
|
];
|
|
|
|
|
|
/** 重量/托盘硬上限(超限时站点拦截提交或无法即时报价) */
|
|
|
export const PRIORITY1_WEIGHT_LIMITS: Record<
|
|
|
string,
|
|
|
{ maxLb: number; minLb?: number; maxPallets?: number; siteMessage?: string }
|
|
|
> = {
|
|
|
"expedited:sprinter_van": {
|
|
|
minLb: 1,
|
|
|
maxLb: 3500,
|
|
|
siteMessage: "Sprinter 卡片标注约 3500 lb 上限",
|
|
|
},
|
|
|
"expedited:small_straight": {
|
|
|
minLb: 1,
|
|
|
maxLb: 8000,
|
|
|
},
|
|
|
"expedited:large_straight": {
|
|
|
minLb: 1,
|
|
|
maxLb: 10000,
|
|
|
maxPallets: 14,
|
|
|
siteMessage:
|
|
|
"Expedited shipments must be 10,000 lbs or less; for overweight choose Full Truckload – Dry Van with Team Drivers",
|
|
|
},
|
|
|
"ftl:dry_van": { minLb: 1, maxLb: 45000, maxPallets: 26 },
|
|
|
"ftl:open_deck": { minLb: 1, maxLb: 45000, maxPallets: 22 },
|
|
|
"ftl:temperature_controlled": { minLb: 1, maxLb: 40000, maxPallets: 24 },
|
|
|
/** FTL 内嵌 Expedited 子车型沿用 Expedited 拖车重量规则 */
|
|
|
"ftl_expedited:sprinter_van": { minLb: 1, maxLb: 3500 },
|
|
|
"ftl_expedited:small_straight": { minLb: 1, maxLb: 8000 },
|
|
|
"ftl_expedited:large_straight": { minLb: 1, maxLb: 10000, maxPallets: 14 },
|
|
|
};
|
|
|
|
|
|
function weightKey(
|
|
|
shipmentType: Priority1ShipmentType,
|
|
|
ftlTrailer?: FtlTrailerType,
|
|
|
expeditedTrailer?: ExpeditedTrailerType,
|
|
|
): string | null {
|
|
|
if (shipmentType === "expedited" && expeditedTrailer) {
|
|
|
return `expedited:${expeditedTrailer}`;
|
|
|
}
|
|
|
if (shipmentType === "ftl" && ftlTrailer === "ftl_expedited" && expeditedTrailer) {
|
|
|
return `ftl_expedited:${expeditedTrailer}`;
|
|
|
}
|
|
|
if (shipmentType === "ftl" && ftlTrailer && ftlTrailer !== "ftl_expedited") {
|
|
|
return `ftl:${ftlTrailer}`;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/** 校验 canonical / API 入参是否违反已知站点规则 */
|
|
|
export function validatePriority1FormConstraints(
|
|
|
demo: Priority1DemoInput,
|
|
|
): Priority1ConstraintViolation[] {
|
|
|
const out: Priority1ConstraintViolation[] = [];
|
|
|
const zipOk = /^\d{5}$/;
|
|
|
|
|
|
if (!zipOk.test(demo.originZip) || !zipOk.test(demo.destinationZip)) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "ZIP_FORMAT",
|
|
|
message: "ZIP 须为 5 位数字",
|
|
|
field: "originZip",
|
|
|
});
|
|
|
}
|
|
|
if (demo.originZip === demo.destinationZip) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "ZIP_SAME",
|
|
|
message: "起运与目的 ZIP 不能相同",
|
|
|
field: "destinationZip",
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (demo.shipmentType === "ftl" && demo.ftlTrailer === "open_deck" && !demo.openDeckSubtype?.trim()) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "OPEN_DECK_SUBTYPE",
|
|
|
message: "Open Deck 必须选择子类型",
|
|
|
field: "openDeckSubtype",
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (demo.shipmentType === "ftl" && demo.ftlTrailer === "temperature_controlled") {
|
|
|
if (!demo.tempMinF?.trim() || !demo.tempMaxF?.trim()) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "TEMP_RANGE",
|
|
|
message: "温控须填写最低与最高温度",
|
|
|
field: "tempMinF",
|
|
|
});
|
|
|
} else if (Number(demo.tempMinF) > Number(demo.tempMaxF)) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "TEMP_ORDER",
|
|
|
message: "最低温度不能高于最高温度",
|
|
|
field: "tempMinF",
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
demo.expeditedLiftgate &&
|
|
|
!expeditedLiftgateAvailable(
|
|
|
demo.shipmentType === "ftl"
|
|
|
? demo.ftlExpeditedTrailer
|
|
|
: demo.expeditedTrailer,
|
|
|
)
|
|
|
) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "LIFTGATE_TRAILER",
|
|
|
message: "Liftgate 仅适用于 Large Straight Box Truck",
|
|
|
field: "expeditedLiftgate",
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const wKey = weightKey(
|
|
|
demo.shipmentType,
|
|
|
demo.ftlTrailer,
|
|
|
demo.shipmentType === "ftl" ? demo.ftlExpeditedTrailer : demo.expeditedTrailer,
|
|
|
);
|
|
|
const weight = Number(demo.weightLb);
|
|
|
if (wKey && Number.isFinite(weight)) {
|
|
|
const lim = PRIORITY1_WEIGHT_LIMITS[wKey];
|
|
|
if (lim) {
|
|
|
if (lim.minLb != null && weight < lim.minLb) {
|
|
|
out.push({
|
|
|
severity: "warning",
|
|
|
code: "WEIGHT_LOW",
|
|
|
message: `${wKey} 建议重量 ≥ ${lim.minLb} lb`,
|
|
|
field: "weightLb",
|
|
|
});
|
|
|
}
|
|
|
if (weight > lim.maxLb) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "WEIGHT_MAX",
|
|
|
message: `重量 ${weight} lb 超过 ${wKey} 上限 ${lim.maxLb} lb${lim.siteMessage ? `(${lim.siteMessage})` : ""}`,
|
|
|
field: "weightLb",
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
demo.shipmentType === "ftl" &&
|
|
|
demo.ftlTrailer !== "ftl_expedited" &&
|
|
|
demo.palletCount
|
|
|
) {
|
|
|
const pallets = Number(demo.palletCount);
|
|
|
const lim = PRIORITY1_WEIGHT_LIMITS[`ftl:${demo.ftlTrailer}`];
|
|
|
if (lim?.maxPallets != null && pallets > lim.maxPallets) {
|
|
|
out.push({
|
|
|
severity: "error",
|
|
|
code: "PALLETS_MAX",
|
|
|
message: `托盘数 ${pallets} 超过 ${demo.ftlTrailer} 建议上限 ${lim.maxPallets}`,
|
|
|
field: "palletCount",
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
export function formatPriority1ConstraintsMarkdown(): string {
|
|
|
const lines = [
|
|
|
"# Priority1 填写规则与限制",
|
|
|
"",
|
|
|
"## 互斥与结构",
|
|
|
"",
|
|
|
...PRIORITY1_MUTEX_RULES.map((r) => `- **${r.id}**:${r.rule}`),
|
|
|
"",
|
|
|
"## 重量 / 托盘上限(lb)",
|
|
|
"",
|
|
|
"| 场景 | 最小 | 最大重量 | 最大托盘 | 备注 |",
|
|
|
"|------|------|----------|----------|------|",
|
|
|
];
|
|
|
for (const [key, lim] of Object.entries(PRIORITY1_WEIGHT_LIMITS)) {
|
|
|
lines.push(
|
|
|
`| ${key} | ${lim.minLb ?? "—"} | ${lim.maxLb} | ${lim.maxPallets ?? "—"} | ${lim.siteMessage ?? ""} |`,
|
|
|
);
|
|
|
}
|
|
|
lines.push(
|
|
|
"",
|
|
|
"## 报价终态(非表单错误)",
|
|
|
"",
|
|
|
"- **即时报价**:LTL 卡片 / FTL Dry Van·Open Deck 部分子类型 日历报价",
|
|
|
"- **人工跟进**:温控、部分 Open Deck、FTL/主类型 Expedited 等 → 「MORE INFORMATION NEEDED」",
|
|
|
"- 人工跟进 **不算 RPA 失败**,返回:表单已提交,站点判定需人工确认细节后报价",
|
|
|
"",
|
|
|
);
|
|
|
return `${lines.join("\n")}\n`;
|
|
|
}
|