Compare commits

..
5 changed files with 294 additions and 23 deletions
@@ -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]],
+3 -1
View File
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
date(2026, 9, 20),
date(2026, 10, 10),
}
)
+43 -1
View File
@@ -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]] = []
+22 -12
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from agent.policy.deal_followup import (
SHANGHAI,
air_route_keys,
is_workday,
leader_matches,
next_followup_at,
shift_workdays,
@@ -20,33 +21,42 @@ from agent.policy.deal_followup import (
class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
# 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。
self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18))
self.assertEqual(shift_workdays(date(2026, 11, 13), 4), date(2026, 11, 19))
def test_sept20_makeup_pulls_followup_forward(self) -> None:
# 2026-09-18 周五。9 月 20 日周日调休上班,第四个工作日是 23 日,不是 24 日。
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 23))
def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
# 2026-09-30 周三。10 月 1–7 日放假,8 日上班,10 日周六调休。
# 节后第 1 个工作日 8 日,第 4 个是 12 日周一。
fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6)
self.assertEqual(fourth, date(2026, 10, 12))
def test_oct8_works_and_sept20_is_makeup(self) -> None:
# 国办发明电〔2025〕7号:10 月 8 日正常上班,9 月 20 日周日调休上班。
self.assertTrue(is_workday(date(2026, 10, 8)))
self.assertTrue(is_workday(date(2026, 9, 20)))
self.assertFalse(is_workday(date(2026, 10, 7)))
def test_next_followup_is_9am(self) -> None:
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
self.assertEqual(when.hour, 9)
self.assertEqual(when.minute, 0)
self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24))
self.assertEqual(when.date(), date(2026, 9, 23))
def test_skip_weekend_keep_weekday_night(self) -> None:
start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五
now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
def test_rest_day_start_counts_from_next_workday(self) -> None:
start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六
now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一
start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六
now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
def test_fractional_quarter_hour(self) -> None:
+158
View File
@@ -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()