""" 报价模板匹配:询价需求对模板关键词,未命中则同业务默认模板。 本文件职责:纯函数,与 Java QuoteTemplateMatcher 同一套规则。 调用:MemoryLedger / 单测;生产出单以主账 render 为准。 禁止:在此发明价格;禁止读仓库 xlsx 当生产真相。 """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Iterable, Optional # 与 Java QuoteTemplateMatcher.FAIL_MESSAGE 保持一字不差 FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。" @dataclass(frozen=True) class TemplatePick: """一次匹配结果。""" template_id: str name: str match_type: str raw: dict[str, Any] def normalize_biz(raw: str) -> str: """业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。""" t = (raw or "").strip() if not t: return "" lower = t.lower() if lower == "air" or t == "空" or "空运" in t or t == "AIR": return "空运" if lower == "sea" or t == "海" or "海运" in t or t == "SEA": return "海运" if lower == "land" or t == "陆" or "陆运" in t or t == "LAND": return "陆运" return t def _as_keywords(raw: Any) -> list[str]: """摊开关键词。列表里若整段是 [海运,整柜] 也要拆开,与 Java asKeywords 一致。""" if raw is None: return [] if isinstance(raw, (list, tuple)): out: list[str] = [] for x in raw: s = str(x).strip() if s.startswith("["): out.extend(_as_keywords(s)) elif s: out.append(s) return out text = str(raw).strip() if not text or text.lower() == "null": return [] if text.startswith("["): inner = text[1:-1] if text.endswith("]") else text[1:] out: list[str] = [] for part in inner.split(","): s = part.strip().strip("\"'") if s: out.append(s) return out return [text] def _is_default(raw: Any) -> bool: if raw is True: return True if isinstance(raw, (int, float)) and int(raw) == 1: return True s = str(raw or "").strip().lower() return s in {"1", "true"} def _append(parts: list[str], val: Any) -> None: if val is None: return s = str(val).strip() if not s or s.lower() == "null": return parts.append(s) def build_inquiry_haystack( *, business_line: str, facts: Optional[dict[str, Any]] = None, quote: Optional[dict[str, Any]] = None, ) -> str: """拼匹配文本:已冻结字段 + 业务线 + 报价费用名。""" parts: list[str] = [] _append(parts, business_line) _append(parts, normalize_biz(business_line)) for key, val in (facts or {}).items(): _append(parts, key) _append(parts, val) q = quote or {} for key in ("source", "source_label", "freight", "total"): _append(parts, q.get(key)) lines = q.get("fee_lines") or [] if isinstance(lines, Iterable) and not isinstance(lines, (str, bytes)): for row in lines: if isinstance(row, dict): _append(parts, row.get("name")) _append(parts, row.get("amount")) else: _append(parts, row) return " ".join(parts) def _row_of(item: Any) -> dict[str, Any]: if isinstance(item, dict): return item return { "templateId": getattr(item, "template_id", "") or getattr(item, "code", ""), "name": getattr(item, "filename", "") or getattr(item, "code", ""), "bizType": getattr(item, "biz_type", ""), "keywords": list(getattr(item, "keywords", None) or []), "isDefault": bool(getattr(item, "is_default", False)), } def pick_quote_template( *, templates: Optional[list[Any]], business_line: str, facts: Optional[dict[str, Any]] = None, quote: Optional[dict[str, Any]] = None, ) -> Optional[TemplatePick]: """ 同业务线:先按关键词打分(运输类型上的词优先),再退回默认模板。 「陆运/国内」不当关键词,避免中港模板抢走拼车单。 参数 templates:主账 listEnabled 行或 TemplateEntry。 返回 None 时销售侧用 FAIL_MESSAGE。 """ want = normalize_biz(business_line) haystack = build_inquiry_haystack( business_line=business_line, facts=facts, quote=quote ).lower() type_val = str((facts or {}).get("运输类型") or "").strip().lower() keyword_hit: Optional[dict[str, Any]] = None best: tuple[int, int] = (-1, -1) default_hit: Optional[dict[str, Any]] = None for item in templates or []: row = _row_of(item) biz = normalize_biz(str(row.get("bizType") or "")) if want and biz and want != biz: continue if default_hit is None and _is_default(row.get("isDefault")): default_hit = row score = _keyword_score(row.get("keywords"), haystack, type_val) if score == (0, 0): continue if score > best: best = score keyword_hit = row hit = keyword_hit or default_hit if hit is None: return None match_type = "keyword" if keyword_hit is not None else "default" return TemplatePick( template_id=str(hit.get("templateId") or hit.get("id") or ""), name=str(hit.get("name") or ""), match_type=match_type, raw=hit, ) _GENERIC_KEYWORDS = frozenset( {"海运", "空运", "陆运", "sea", "air", "land", "国内"} ) def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]: """ 运输类型上的命中优先,其次全文词长。 「陆运/国内」跳过,避免中港、国内整车抢走拼车单。 """ type_hits = 0 length = 0 any_hit = False for kw in _as_keywords(raw): k = kw.lower() if k in _GENERIC_KEYWORDS: continue if type_val and k in type_val: type_hits += 1 length += len(k) any_hit = True elif k in haystack: length += len(k) any_hit = True if not any_hit: return (0, 0) return (type_hits, length)