@@ -0,0 +1,193 @@
|
||||
"""
|
||||
事件异常纯规则:等待种类、超时编码、点名对象、卡片接收人。
|
||||
|
||||
本文件职责:给 Worker 巡检判定用,不发企微、不写主账。
|
||||
禁止:在回调线程做 IO;把六态写进本文件;把系统异常混进来。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.land_options import canonicalize_land_class
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
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_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 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"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
事件异常纯规则:超时编码、点名对象、卡片接收人(不打外网)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.policy.event_exception import (
|
||||
WAIT_ADOPT,
|
||||
WAIT_AIR_QUOTE,
|
||||
WAIT_DEAL,
|
||||
WAIT_PRODUCT_QUOTE,
|
||||
conversation_of,
|
||||
format_timeout_hours,
|
||||
pick_app_targets,
|
||||
pick_mentions,
|
||||
timeout_code,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeoutCode(unittest.TestCase):
|
||||
def test_sales_unified(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_ADOPT, business_line="SEA", transport_class="急件"),
|
||||
"sea_sales",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_DEAL, business_line="AIR", transport_class=""),
|
||||
"air_sales",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind="lost_reason", business_line="LAND", transport_class="急件"),
|
||||
"land_sales",
|
||||
)
|
||||
|
||||
def test_air_route(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_AIR_QUOTE, business_line="AIR", transport_class=""),
|
||||
"air_route",
|
||||
)
|
||||
|
||||
def test_sea_and_land_product_class(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="Port to Port"
|
||||
),
|
||||
"sea_product_ptp",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="其他"),
|
||||
"sea_product_other",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
|
||||
"land_hk_express",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_hours_format(self) -> None:
|
||||
self.assertEqual(format_timeout_hours(0.25), "0.25")
|
||||
self.assertEqual(format_timeout_hours(48), "48")
|
||||
|
||||
|
||||
class TestMentions(unittest.TestCase):
|
||||
def test_product_all_unanswered(self) -> None:
|
||||
sales = {
|
||||
"wecomId": "s1",
|
||||
"name": "销售甲",
|
||||
"roleCode": "sales",
|
||||
"notifyEventException": 1,
|
||||
}
|
||||
members = [
|
||||
{"wecomId": "p1", "name": "张三", "roleCode": "sea", "notifyEventException": 1},
|
||||
{"wecomId": "p2", "name": "李四", "roleCode": "sea", "notifyEventException": 0},
|
||||
{"wecomId": "s1", "name": "销售甲", "roleCode": "sales", "notifyEventException": 1},
|
||||
]
|
||||
hit = pick_mentions(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE,
|
||||
sales=sales,
|
||||
members=members,
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual([p["name"] for p in hit], ["张三", "李四"])
|
||||
apps = pick_app_targets(hit)
|
||||
self.assertEqual([p["wecomId"] for p in apps], ["p1"])
|
||||
|
||||
def test_adopt_is_private(self) -> None:
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_ADOPT, collab_chat_id="wrxxx"), "private")
|
||||
|
||||
def test_deal_uses_group_when_present(self) -> None:
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id="wrxxx"), "group")
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id=""), "private")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user