""" 事件异常纯规则:等待种类、超时编码、点名对象、卡片接收人。 本文件职责:给 Worker 巡检判定用,不发企微、不写主账。 禁止:在回调线程做 IO;把六态写进本文件;把系统异常混进来。 """ from __future__ import annotations import logging from typing import Any from agent.schema.land_options import canonicalize_land_class from agent.schema.sea_options import canonicalize_sea_class logger = logging.getLogger(__name__) WAIT_AIR_QUOTE = "air_quote" WAIT_PRODUCT_QUOTE = "product_quote" WAIT_ADOPT = "adopt" WAIT_DEAL = "deal" WAIT_LOST_REASON = "lost_reason" TYPE_AIR = "航线响应超时" TYPE_PRODUCT = "产品响应超时" TYPE_SALES = "销售响应超时" CARD_AIR = "航线响应异常" CARD_PRODUCT = "产品响应异常" CARD_SALES = "销售响应异常" _SALES_CODES = { "AIR": "air_sales", "SEA": "sea_sales", "LAND": "land_sales", } _LAND_PRODUCT_CODES = { "普件(常用线路/常规车型)": "land_common", "普件(东南亚、中港常用线路/常规车型)": "land_hk_common", "普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross", "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special", "急件": "land_hk_express", } _SALES_WAITS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON} def timeout_code(*, wait_kind: str, business_line: str, transport_class: str) -> str: """ 按等待种类 + 运输方式 + 询价分类,对上后台超时规则编码。 对不上返回空串,调用方视为本步不监测。副作用:无。 """ line = (business_line or "").strip().upper() kind = (wait_kind or "").strip() klass = (transport_class or "").strip() if kind in _SALES_WAITS: return _SALES_CODES.get(line, "") if kind == WAIT_AIR_QUOTE: return "air_route" if kind == WAIT_PRODUCT_QUOTE: if line == "SEA": canon = canonicalize_sea_class(klass) or klass if canon == "Port to Port": return "sea_product_ptp" if canon == "其他": return "sea_product_other" return "" if line == "LAND": canon = canonicalize_land_class(klass) or klass return _LAND_PRODUCT_CODES.get(canon, "") return "" return "" def exception_type(wait_kind: str) -> str: """工单「事件异常」字段用的类型名。""" kind = (wait_kind or "").strip() if kind == WAIT_AIR_QUOTE: return TYPE_AIR if kind == WAIT_PRODUCT_QUOTE: return TYPE_PRODUCT if kind in _SALES_WAITS: return TYPE_SALES return "" def card_title(wait_kind: str) -> str: """询价消息服务卡片标题。""" kind = (wait_kind or "").strip() if kind == WAIT_AIR_QUOTE: return CARD_AIR if kind == WAIT_PRODUCT_QUOTE: return CARD_PRODUCT if kind in _SALES_WAITS: return CARD_SALES return "" def format_timeout_hours(hours: float) -> str: """话术里的小时数:整数去小数点,0.25 保持原样。""" try: n = float(hours) except (TypeError, ValueError): return "0" if n == int(n): return str(int(n)) text = f"{n:.4f}".rstrip("0").rstrip(".") return text or "0" def append_summary(existing: str, type_name: str) -> str: """多次异常用分号拼接,同一类型不重复。""" add = (type_name or "").strip() cur = (existing or "").strip() if not add: return cur if not cur or cur == "-": return add parts = [p.strip() for p in cur.split(";") if p.strip()] if add in parts: return cur parts.append(add) return ";".join(parts) def start_wait(ledger: Any, *, work_order_no: str, wait_kind: str) -> None: """开始盯等待。失败只打日志,不抛给回调。""" no = (work_order_no or "").strip() kind = (wait_kind or "").strip() if not no or not kind or ledger is None: return fn = getattr(ledger, "start_event_wait", None) if not callable(fn): return try: fn(work_order_no=no, wait_kind=kind) except Exception: logger.exception("start_event_wait 失败 wo=%s kind=%s", no, kind) def clear_wait(ledger: Any, *, work_order_no: str) -> None: """清空当前等待,不改事件异常摘要。失败只打日志。""" no = (work_order_no or "").strip() if not no or ledger is None: return fn = getattr(ledger, "clear_event_wait", None) if not callable(fn): return try: fn(work_order_no=no) except Exception: logger.exception("clear_event_wait 失败 wo=%s", no) def pick_mentions( *, wait_kind: str, sales: dict[str, Any] | None, members: list[dict[str, Any]] | None, business_line: str, ) -> list[dict[str, Any]]: """ 本单这次应回而未回、需要被点名的人。 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。 不要询价机器人、不要群里其他人。 """ kind = (wait_kind or "").strip() line = (business_line or "").strip().upper() if kind in _SALES_WAITS: person = dict(sales or {}) uid = str(person.get("wecomId") or person.get("wecom_id") or "").strip() if not uid: return [] person.setdefault("wecomId", uid) person.setdefault("name", str(person.get("name") or "").strip() or "销售") return [person] want = "" if kind == WAIT_AIR_QUOTE: want = "air" elif kind == WAIT_PRODUCT_QUOTE: if line == "SEA": want = "sea" elif line == "LAND": want = "land" if not want: return [] out: list[dict[str, Any]] = [] seen: set[str] = set() for raw in members or []: if not isinstance(raw, dict): continue role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() if role != want: continue uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() if not uid or uid in seen: continue seen.add(uid) row = dict(raw) row.setdefault("wecomId", uid) row.setdefault("name", str(raw.get("name") or "").strip() or uid) out.append(row) return out def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]: """勾了「接收事件异常消息提醒」且绑了企微的人。""" out: list[dict[str, Any]] = [] seen: set[str] = set() for raw in people or []: if not isinstance(raw, dict): continue flag = raw.get("notifyEventException") if flag not in (1, True, "1"): continue uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() if not uid or uid in seen: continue seen.add(uid) out.append(raw) return out def conversation_of(*, wait_kind: str, collab_chat_id: str) -> str: """ 告警发在哪个会话:group 或 private。 采用/不采用永远私聊;航线/产品永远群;成交跟进和未成交原因有协同群才群。 """ kind = (wait_kind or "").strip() chat = (collab_chat_id or "").strip() if kind == WAIT_ADOPT: return "private" if kind in {WAIT_AIR_QUOTE, WAIT_PRODUCT_QUOTE}: return "group" if kind in {WAIT_DEAL, WAIT_LOST_REASON}: return "group" if chat else "private" return "private"