/** * 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。 * * 口径对齐销售侧询价确认卡(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 = { 起运港: "始发站", 目的港: "目的地", 品名: "货物品名", 毛重: "重量(KG)", 体积: "体积(CBM)", "车型/数量": "车型/数量", 通关口岸: "通关口岸", 报价日期: "报价日期", }; /** * JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。 * 协同快照里的车型等也要能展示,调用方自己决定合并顺序。 */ export function parseFactsMap(raw: unknown): Record { 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 = {}; for (const key of Object.keys(obj)) { const val = obj[key]; if (val == null) continue; if (typeof val === "object") continue; const text = String(val).trim(); if (text) out[key] = text; } return out; } /** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */ export function mergeInquiryFacts(ticket: any): Record { const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts); const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts); const out: Record = { ...inquiry }; for (const [k, v] of Object.entries(collab)) { if (!out[k]) out[k] = v; } return out; } function pick(facts: Record, ...keys: string[]): string { for (const key of keys) { const v = (facts[key] || "").trim(); if (v) return v; } 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 = { 起运港: 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, "包装方式", "包装类型")); 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(" · "); }