242 lines
7.4 KiB
Python
242 lines
7.4 KiB
Python
"""
|
||
事件异常纯规则:等待种类、超时编码、点名对象、卡片接收人。
|
||
|
||
本文件职责:给 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"
|