/** * 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。 * * 口径对齐销售侧询价确认卡(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 当空。 * 协同快照里的车型等也要能展示,调用方自己决定合并顺序。 * segments_json 若已是数组/对象,序列化成字符串留给 parseSegments,禁止直接丢掉。 */ 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") { // 多段 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 { 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 { const d = pick(sf, "货好时间", "cargo_ready_date"); return d ? `货好 ${d}` : ""; } /** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */ 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 ""; } const MODE_WORD: Record = { AIR: "空运", SEA: "海运", LAND: "陆运" }; /** 从 facts.segments_json 解出各段;坏 JSON 当没有段。 */ function parseSegments(facts: Record): Array<{ index: number; mode: string; modeWord: string; facts: Record; }> { 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 = { 起运港: 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(" · "); }