From 2e8918a5c9a19a848734fed660ea9a585974cbe2 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Fri, 9 Oct 2026 00:50:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A9=BA=E8=BF=90=E7=BE=A4=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=89=BE=E4=B8=8D=E5=88=B0=E8=88=AA=E7=BA=BF=E6=88=90=E5=91=98?= =?UTF-8?q?=E6=97=B6=E5=86=99=E6=88=90=E4=BA=86=E3=80=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E3=80=8D=E3=80=82=E6=94=B9=E4=B8=BA=20@=20?= =?UTF-8?q?=E8=88=AA=E7=BA=BF=E5=90=8C=E4=BA=8B=EF=BC=9B=E9=94=80=E5=94=AE?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=8F=AA=20@=20=E6=9C=AC=E5=8D=95=E9=94=80?= =?UTF-8?q?=E5=94=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../agent/jobs/event_exception_poll.py | 77 ++++++++- inquiry-agent/agent/policy/event_exception.py | 44 ++++- inquiry-agent/tests/test_event_exception.py | 158 ++++++++++++++++++ 3 files changed, 269 insertions(+), 10 deletions(-) diff --git a/inquiry-agent/agent/jobs/event_exception_poll.py b/inquiry-agent/agent/jobs/event_exception_poll.py index 371f194..359fd7e 100644 --- a/inquiry-agent/agent/jobs/event_exception_poll.py +++ b/inquiry-agent/agent/jobs/event_exception_poll.py @@ -18,6 +18,11 @@ from agent.policy.event_exception import ( card_title, conversation_of, exception_type, + WAIT_ADOPT, + WAIT_AIR_QUOTE, + WAIT_DEAL, + WAIT_LOST_REASON, + mentions_for_role, pick_app_targets, pick_mentions, timeout_code, @@ -25,6 +30,9 @@ from agent.policy.event_exception import ( logger = logging.getLogger(__name__) +# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。 +_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON} + SendGroupFn = Callable[..., bool] EnqueueFn = Callable[..., tuple[bool, str]] TimeoutLookupFn = Callable[..., dict[str, Any]] @@ -149,19 +157,17 @@ def _handle_one( if workday_elapsed_hours(started, now) < hours: return False watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] - sales = next((dict(w) for w in watchers if str(w.get("roleCode") or "").lower() in {"sales", ""}), None) - if sales is None: - sales = { - "wecomId": str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip(), - "name": str(row.get("sales_name") or row.get("salesName") or "销售").strip() or "销售", - "roleCode": "sales", - "notifyEventException": 0, - } + sales = _ticket_sales(row, watchers) people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line) + # 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。 + # 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。 + if kind == WAIT_AIR_QUOTE and not people: + people = _airline_staff_mentions(book) names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()] chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() where = conversation_of(wait_kind=kind, collab_chat_id=chat_id) - mention = where == "group" + # 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。 + mention = where == "group" or kind in _SALES_WAIT_KINDS text = copy.event_exception_session_text( exception_type=exception_type(kind), work_order_no=no, @@ -208,6 +214,59 @@ def _handle_one( return True +def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]: + """ + 这张工单的销售。销售超时只 @ 这个人。 + + 先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。 + 姓名用工单上的销售名,避免点成「销售」两个字或点到航线。 + """ + sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip() + sname = str(row.get("sales_name") or row.get("salesName") or "").strip() + matched: dict[str, Any] | None = None + for raw in watchers: + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() + if sid and uid == sid: + matched = dict(raw) + break + if matched is None and role == "sales": + matched = dict(raw) + if matched is None: + matched = { + "wecomId": sid, + "roleCode": "sales", + "notifyEventException": 0, + } + name = str(matched.get("name") or "").strip() + if sname and (not name or name in {sid, "销售"}): + matched["name"] = sname + elif not name: + matched["name"] = sname or "销售" + if sid and not str(matched.get("wecomId") or "").strip(): + matched["wecomId"] = sid + matched.setdefault("roleCode", "sales") + return matched + + +def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]: + """ + 主账按岗位列出启用的航线同事。 + + 只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。 + 不写工单。可与巡检线程并发,每次只读这一单的员工名单。 + """ + lister = getattr(book, "list_staff_by_role", None) + if not callable(lister): + return [] + try: + rows = list(lister(role_code="air") or []) + except Exception: + logger.exception("事件异常列航线岗失败") + return [] + return mentions_for_role(rows, role_code="air") + + def _send_app_cards( *, people: list[dict[str, Any]], diff --git a/inquiry-agent/agent/policy/event_exception.py b/inquiry-agent/agent/policy/event_exception.py index 3bcdd6e..e2be884 100644 --- a/inquiry-agent/agent/policy/event_exception.py +++ b/inquiry-agent/agent/policy/event_exception.py @@ -164,7 +164,7 @@ def pick_mentions( """ 本单这次应回而未回、需要被点名的人。 - 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。 + 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。 不要询价机器人、不要群里其他人。 """ kind = (wait_kind or "").strip() @@ -206,6 +206,48 @@ def pick_mentions( return out +def mentions_for_role( + rows: list[dict[str, Any]] | None, + *, + role_code: str, +) -> list[dict[str, Any]]: + """ + 从员工行里取出启用、有企微、有姓名的指定岗位。 + + 空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时, + 用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。 + 不发消息、不写主账。 + """ + want = (role_code or "").strip().lower() + if not want: + return [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw in rows or []: + if not isinstance(raw, dict): + continue + role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() + if role != want: + continue + status = str(raw.get("status") or "active").strip().lower() + if status in {"disabled", "0", "停用", "inactive"}: + continue + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + name = str(raw.get("name") or "").strip() + if not uid or not name or uid in seen: + continue + seen.add(uid) + out.append( + { + "wecomId": uid, + "name": name, + "roleCode": want, + "notifyEventException": raw.get("notifyEventException", 0), + } + ) + return out + + def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]: """勾了「接收事件异常消息提醒」且绑了企微的人。""" out: list[dict[str, Any]] = [] diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py index ae58e14..be14b0d 100644 --- a/inquiry-agent/tests/test_event_exception.py +++ b/inquiry-agent/tests/test_event_exception.py @@ -20,6 +20,7 @@ from agent.policy.event_exception import ( WAIT_PRODUCT_QUOTE, conversation_of, format_timeout_hours, + mentions_for_role, pick_app_targets, pick_mentions, timeout_code, @@ -270,6 +271,163 @@ class TestPoll(unittest.TestCase): ) self.assertEqual(n2, 0) + def test_air_group_names_airline_when_members_missing(self) -> None: + """空运群没记下成员时,超时要 @ 航线姓名,不能写成相关人员。""" + import importlib.util + from datetime import datetime, timedelta + + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.deal_followup import SHANGHAI + + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") + spec = importlib.util.spec_from_file_location("ee_poll_air", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + book = MemoryLedger() + book.set_sea_staff( + [ + { + "wecomId": "sales1", + "name": "罗小华", + "roleCode": "sales", + "status": "active", + }, + { + "wecomId": "air1", + "name": "吴季林", + "roleCode": "air", + "status": "active", + }, + ] + ) + created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"}) + wo = created["work_order_no"] + book.start_event_wait(work_order_no=wo, wait_kind="air_quote") + ticket = book.get_ticket(work_order_no=wo) + ticket.collab_chat_id = "wr-air" + ticket.collab_member_ids = [] + ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + book._timeouts["air_route"] = 0.25 + groups: list[dict] = [] + + def send_group(*, chat_id: str, content: str, business_line: str = "") -> bool: + groups.append({"chat_id": chat_id, "content": content, "line": business_line}) + return True + + n = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + send_group=send_group, + notify_ready=False, + ) + self.assertEqual(n, 1) + self.assertEqual(len(groups), 1) + self.assertEqual( + groups[0]["content"], + f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("相关人员", groups[0]["content"]) + + def test_sales_timeout_mentions_sales_not_airline(self) -> None: + """销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。""" + import importlib.util + from datetime import datetime, timedelta + + from agent.ledger.memory_ledger import MemoryLedger + from agent.policy.deal_followup import SHANGHAI + + path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py") + spec = importlib.util.spec_from_file_location("ee_poll_sales", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + book = MemoryLedger() + book.set_sea_staff( + [ + {"wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active"}, + {"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"}, + ] + ) + created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={}) + wo = created["work_order_no"] + book.start_event_wait(work_order_no=wo, wait_kind="adopt") + ticket = book.get_ticket(work_order_no=wo) + ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + book._timeouts["air_sales"] = 0.25 + private: list[dict] = [] + + def enqueue(**kwargs): + private.append(kwargs) + return True, "" + + def send_group(**kwargs) -> bool: + raise AssertionError("销售采用超时不应发到群") + + n = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + enqueue=enqueue, + send_group=send_group, + notify_ready=False, + ) + self.assertEqual(n, 1) + self.assertEqual(len(private), 1) + self.assertEqual( + private[0]["content"], + f"销售响应超时\n关联询价工单 {wo}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("吴季林", private[0]["content"]) + self.assertNotIn("相关人员", private[0]["content"]) + + created_deal = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"目的港": "胡志明"}) + deal_no = created_deal["work_order_no"] + book.start_event_wait(work_order_no=deal_no, wait_kind="deal") + deal = book.get_ticket(work_order_no=deal_no) + deal.collab_chat_id = "wr-air" + deal.event_wait_started_at = ticket.event_wait_started_at + groups: list[dict] = [] + + def send_deal_group(*, chat_id: str, content: str, business_line: str = "") -> bool: + groups.append({"chat_id": chat_id, "content": content}) + return True + + n_deal = mod.poll_event_exceptions( + ledger=book, + now=datetime.now(SHANGHAI), + enqueue=enqueue, + send_group=send_deal_group, + notify_ready=False, + ) + self.assertEqual(n_deal, 1) + self.assertEqual( + groups[0]["content"], + f"销售响应超时\n关联询价工单 {deal_no}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。", + ) + self.assertNotIn("吴季林", groups[0]["content"]) + + +class TestMentionsForRole(unittest.TestCase): + def test_keeps_active_named_role_only(self) -> None: + rows = mentions_for_role( + [ + {"wecomId": "a1", "name": "吴季林", "roleCode": "air", "status": "active"}, + {"wecomId": "s1", "name": "罗小华", "roleCode": "sales", "status": "active"}, + {"wecomId": "a2", "name": "停用航线", "roleCode": "air", "status": "disabled"}, + {"wecomId": "a3", "name": "", "roleCode": "air", "status": "active"}, + ], + role_code="air", + ) + self.assertEqual([row["name"] for row in rows], ["吴季林"]) + if __name__ == "__main__": unittest.main()