Files
inquiry_robot/inquiry-backend/src/views/inquiry/workOrder/demandFields.ts
T

381 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。
*
* 口径对齐销售侧询价确认卡(inquiry_field_lines):
* 空运看件数/重量/体积/包装;海运看货量/整柜拼柜/箱型箱量;
* 陆运拼车看体积重量,整车/中港零担看车型数量,中港整车不强制车型。
* 只做展示,不改六态、不写主账。
*/
/** 详情格子一行:标签 + 值(空则「-」) */
export type DemandField = { label: string; value: string };
const LAND_FTL_TYPES = new Set(["国内运输整车", "跨境整车", "中港零担/集拼"]);
const LAND_CUSTOMS_TYPES = new Set(["跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"]);
const LAND_LABEL: Record<string, string> = {
起运港: "始发站",
目的港: "目的地",
品名: "货物品名",
毛重: "重量(KG)",
体积: "体积(CBM)",
"车型/数量": "车型/数量",
通关口岸: "通关口岸",
报价日期: "报价日期",
};
/**
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
* segments_json 若已是数组/对象,序列化成字符串留给 parseSegments,禁止直接丢掉。
*/
export function parseFactsMap(raw: unknown): Record<string, string> {
if (raw == null) return {};
let obj: any = raw;
if (typeof raw === "string" && raw.trim()) {
try {
obj = JSON.parse(raw);
} catch {
return {};
}
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
const out: Record<string, string> = {};
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val == null) continue;
if (typeof val === "object") {
// 多段 segments_json 常被反序列成数组;保留才能在详情里画出段1/段2。
if (
(key === "segments_json" || key === "segmentsJson") &&
(Array.isArray(val) || typeof val === "object")
) {
try {
out[key] = JSON.stringify(val);
} catch {
/* ignore */
}
}
continue;
}
const text = String(val).trim();
if (text) out[key] = text;
}
return out;
}
/** 列表子工单行:一段的运输方式码 + 展示文案。 */
export type MultiSegmentView = {
index: number;
/** AIR / SEA / LAND */
mode: string;
modeWord: string;
/** 子工单左侧详情:海运 · 上海 → 汉堡 · … */
detailLine: string;
/** 旧列表兼容:段1陆运 北京 → 上海 茶叶 */
summaryLine: string;
};
/**
* 多段各段一行摘要(段1陆运 北京 → 上海)。无段返回空数组。
*/
export function multiSegmentSummaryLines(ticket: any): string[] {
return multiSegmentViews(ticket).map((seg) => seg.summaryLine);
}
/**
* 多段子工单视图:列表父行下灰底子行用。
* 详情串尽量贴近原型「方式 · 路线 · 货量条款」;缺字段就跳过,不编造。
*/
export function multiSegmentViews(ticket: any): MultiSegmentView[] {
const facts = mergeInquiryFacts(ticket);
const segs = parseSegments(facts);
return segs.map((seg) => {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
const route = o || d ? `${o || "-"} → ${d || "-"}` : "";
const bits: string[] = [seg.modeWord];
if (route) bits.push(route);
const extras = [
pick(sf, "运输条款", "贸易条款"),
pick(sf, "整柜或拼柜"),
pick(sf, "箱型箱量"),
pick(sf, "包装方式", "包装类型"),
pick(sf, "运输类型"),
pick(sf, "客户"),
volumeWeightText(sf),
pick(sf, "车型/数量", "车型"),
readyDateText(sf),
g ? `品名 ${g}` : "",
].filter(Boolean);
bits.push(...extras);
return {
index: seg.index,
mode: seg.mode,
modeWord: seg.modeWord,
detailLine: bits.join(" · "),
summaryLine: [`段${seg.index}${seg.modeWord}`, route, g].filter(Boolean).join(" "),
};
});
}
function volumeWeightText(sf: Record<string, string>): string {
const vol = pick(sf, "体积");
const w = pick(sf, "毛重", "重量");
if (vol && w) return `${vol} / ${w}`;
if (vol) return vol;
if (w) return w;
return "";
}
function readyDateText(sf: Record<string, string>): string {
const d = pick(sf, "货好时间", "cargo_ready_date");
return d ? `货好 ${d}` : "";
}
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
export function mergeInquiryFacts(ticket: any): Record<string, string> {
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts);
const out: Record<string, string> = { ...inquiry };
for (const [k, v] of Object.entries(collab)) {
if (!out[k]) out[k] = v;
}
return out;
}
function pick(facts: Record<string, string>, ...keys: string[]): string {
for (const key of keys) {
const v = (facts[key] || "").trim();
if (v) return v;
}
return "";
}
const MODE_WORD: Record<string, string> = { AIR: "空运", SEA: "海运", LAND: "陆运" };
/** 从 facts.segments_json 解出各段;坏 JSON 当没有段。 */
function parseSegments(facts: Record<string, string>): Array<{
index: number;
mode: string;
modeWord: string;
facts: Record<string, string>;
}> {
const raw = (facts.segments_json || facts.segmentsJson || "").trim();
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.filter((x) => x && typeof x === "object")
.map((seg: any, i: number) => {
const mode = String(seg.mode || "").toUpperCase();
const nested =
seg.facts && typeof seg.facts === "object" && !Array.isArray(seg.facts)
? parseFactsMap(seg.facts)
: {};
return {
index: Number(seg.index) > 0 ? Number(seg.index) : i + 1,
mode,
modeWord: MODE_WORD[mode] || mode || "段",
facts: nested,
};
});
} catch {
return [];
}
}
function dash(v: unknown): string {
const s = (v ?? "").toString().trim();
return s ? s : "-";
}
function columnText(v: unknown): string {
if (v == null || v === "") return "";
const s = String(v).trim();
if (!s || s === "-") return "";
return s;
}
function businessLineOf(ticket: any): "AIR" | "SEA" | "LAND" | "MULTI" | "" {
const raw = String(ticket?.businessLine ?? "").trim().toUpperCase();
if (raw === "AIR" || raw === "SEA" || raw === "LAND" || raw === "MULTI") return raw;
const label = String(ticket?.bizTypeLabel ?? ticket?.bizType ?? "").trim();
if (label.includes("空运")) return "AIR";
if (label.includes("海运")) return "SEA";
if (label.includes("陆运")) return "LAND";
if (label.includes("多段") || label.includes("联运")) return "MULTI";
return "";
}
function transportModeLabel(ticket: any, line: string): string {
const label = String(ticket?.bizTypeLabel ?? "").trim();
if (label) return label;
if (line === "AIR") return "空运";
if (line === "SEA") return "海运";
if (line === "LAND") return "陆运";
if (line === "MULTI") return "多段联运";
return "";
}
/** 当前陆运类型要展示的货物内部键。中港整车不要把车型当成询价必填。 */
function landCargoKeys(landType: string): string[] {
if (landType === "中港整车") {
return ["起运港", "目的港", "品名", "报价日期"];
}
if (LAND_FTL_TYPES.has(landType)) {
return ["起运港", "目的港", "品名", "车型/数量", "报价日期"];
}
return ["起运港", "目的港", "品名", "体积", "毛重", "报价日期"];
}
function push(fields: DemandField[], label: string, value: string) {
fields.push({ label, value: dash(value) });
}
/**
* 工单详情询价需求格子。
* 列字段(origin / pieces / containerSpec 等)作 facts 空时的兜底,兼容旧单。
*/
export function buildDemandFields(ticket: any): DemandField[] {
const facts = mergeInquiryFacts(ticket);
const line = businessLineOf(ticket);
const mode = transportModeLabel(ticket, line);
const origin = pick(facts, "起运港", "起运地") || columnText(ticket?.origin);
const dest = pick(facts, "目的港", "目的地") || columnText(ticket?.destination);
const goods = pick(facts, "品名", "货物品名") || columnText(ticket?.goodsName);
const quoteDate =
pick(facts, "报价日期") || columnText(ticket?.quoteDate)?.toString().slice(0, 10) || "";
const customer = pick(facts, "客户名称") || columnText(ticket?.customerName);
const fields: DemandField[] = [];
push(fields, "运输方式", mode);
if (line === "SEA") {
push(fields, "起运港", origin);
push(fields, "目的港", dest);
push(fields, "品名", goods);
push(fields, "货物数量", pick(facts, "货量", "货物数量", "件数") || columnText(ticket?.cargoQty) || columnText(ticket?.pieces));
push(fields, "整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
push(fields, "箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
push(fields, "贸易条款", pick(facts, "贸易条款"));
push(fields, "运输分类", pick(facts, "运输分类"));
push(fields, "报价日期", quoteDate);
} else if (line === "LAND") {
const landType = pick(facts, "运输类型");
push(fields, "运输类型", landType);
push(fields, "线路类别", pick(facts, "线路类别"));
push(fields, "运输分类", pick(facts, "运输分类"));
const cargoVals: Record<string, string> = {
起运港: origin,
目的港: dest,
品名: goods,
体积: pick(facts, "体积") || columnText(ticket?.volumeCbm),
毛重: pick(facts, "毛重", "重量") || columnText(ticket?.weightKg),
"车型/数量": pick(facts, "车型/数量", "车型"),
报价日期: quoteDate,
};
for (const key of landCargoKeys(landType)) {
push(fields, LAND_LABEL[key] || key, cargoVals[key] || pick(facts, key));
}
if (LAND_CUSTOMS_TYPES.has(landType)) {
push(fields, "通关口岸", pick(facts, "通关口岸"));
}
// 中港整车询价不强制车型,协同里有值仍展示,避免后台看不到 53 尺这类原文。
if (landType === "中港整车" && pick(facts, "车型/数量", "车型")) {
push(fields, "车型/数量", pick(facts, "车型/数量", "车型"));
}
} else if (line === "AIR") {
push(fields, "起运地", origin);
push(fields, "目的地", dest);
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
const added = pick(facts, "added_services", "增值服务");
if (added) {
// 没填不占一行,和空运群、建单确认一致。
push(fields, "增值服务", added);
}
push(fields, "报价日期", quoteDate);
} else if (line === "MULTI") {
// 多段:按 segments_json 逐段展示,并带整票工单号。
push(fields, "工单号", columnText(ticket?.workOrderNo) || columnText(ticket?.work_order_no));
const segs = parseSegments(facts);
if (segs.length) {
for (const seg of segs) {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
push(
fields,
`段${seg.index}${seg.modeWord}`,
[o || d ? `${o || "-"} → ${d || "-"}` : "", g, pick(sf, "件数"), pick(sf, "毛重", "重量"), pick(sf, "体积")]
.filter(Boolean)
.join(" · "),
);
}
} else {
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
}
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
push(fields, "报价日期", quoteDate);
} else {
// 未识别:至少把路线和货量列出来,避免整块空白。
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
const box = pick(facts, "箱型箱量") || columnText(ticket?.containerSpec);
if (box) push(fields, "箱型箱量", box);
const vehicle = pick(facts, "车型/数量", "车型");
if (vehicle) push(fields, "车型/数量", vehicle);
}
push(fields, "客户", customer);
return fields;
}
/**
* 列表「询价需求」第二行货量:按方式只写该方式关心的量,避免陆运整车全是件数「-」。
*/
export function quantitySummary(ticket: any): string {
const facts = mergeInquiryFacts(ticket);
const line = businessLineOf(ticket);
const landType = pick(facts, "运输类型");
const parts: string[] = [];
const add = (label: string, raw: string) => {
const v = (raw || "").trim();
if (v && v !== "-") parts.push(`${label} ${v}`);
};
if (line === "SEA") {
add("货物数量", pick(facts, "货量", "货物数量") || columnText(ticket?.cargoQty));
add("整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
add("箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
return parts.join(" · ");
}
if (line === "LAND") {
if (landType) add("运输类型", landType);
if (LAND_FTL_TYPES.has(landType) || (landType === "中港整车" && pick(facts, "车型/数量"))) {
add("车型/数量", pick(facts, "车型/数量", "车型"));
} else {
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
}
return parts.join(" · ");
}
add("件数", pick(facts, "件数") || columnText(ticket?.pieces));
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
return parts.join(" · ");
}