后台按查看权限收紧菜单与「只看自己」数据范围;跨境整车报价填表更稳,协商中顺延不再误报销售超时。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-11 13:04:09 +08:00
co-authored by Cursor
parent ae818f26b1
commit b070a9b7a7
23 changed files with 1383 additions and 498 deletions
@@ -123,6 +123,35 @@ def notify_app_flag(override: Optional[bool]) -> bool:
return notify_app_ready()
def _skip_sales_timeout(
*,
status: str,
wait_kind: str,
next_followup_at: str,
lost_reason: str,
now: datetime,
) -> str:
"""
成交跟进相关等待在终态/顺延窗内不应再告警。
返回跳过原因;空串表示继续按小时判定。
协商中:只要还排着再问时刻(next_followup_at > now),就不发销售超时;
再问认领后会清空该时刻并重新 start deal,那时才重新计时。
"""
st = (status or "").strip()
_ = wait_kind
if st == "未成交" and (lost_reason or "").strip():
return "lost_reason_recorded"
if st == "协商中":
raw = (next_followup_at or "").strip()
if not raw:
return ""
due = _parse_started(raw)
if due is None or due > now:
return "negotiate_followup_pending"
return ""
def _handle_one(
row: dict[str, Any],
*,
@@ -139,6 +168,14 @@ def _handle_one(
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
if not no or not kind:
return False
status = str(row.get("status") or "").strip()
followup = str(row.get("next_followup_at") or row.get("nextFollowupAt") or "").strip()
lost_reason = str(row.get("lost_reason") or row.get("lostReason") or "").strip()
# 产品口径:协商中顺延再问期间不算「销售无响应」;未成交已写原因不再催。
# 即使 clear_wait 失败留下陈旧 deal 等待,这里也要挡住(WO202610060024 / WO202610070034)。
skip_reason = _skip_sales_timeout(
status=status, wait_kind=kind, next_followup_at=followup, lost_reason=lost_reason, now=now
)
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
facts = dict(row.get("facts") or {})
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
@@ -154,6 +191,9 @@ def _handle_one(
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
if started is None:
return False
if skip_reason:
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
return False
if workday_elapsed_hours(started, now) < hours:
return False
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
@@ -344,12 +344,23 @@ class MemoryLedger:
ticket.next_followup_at = ""
if lost_reason:
ticket.lost_reason = lost_reason
# 成交等待结束;若仍缺原因由智能体再 start lost_reason
ticket.event_wait_kind = ""
ticket.event_wait_started_at = ""
ticket.event_wait_alerted = 0
elif to_status == "已成交":
ticket.next_followup_at = ""
if deal_notify_summary:
ticket.deal_notify_summary = deal_notify_summary
ticket.event_wait_kind = ""
ticket.event_wait_started_at = ""
ticket.event_wait_alerted = 0
elif to_status == "协商中":
ticket.next_followup_at = next_followup_at
# 顺延再问期间不盯成交超时;到期再问会重新 start deal
ticket.event_wait_kind = ""
ticket.event_wait_started_at = ""
ticket.event_wait_alerted = 0
return {"ok": True, "status": ticket.status}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
@@ -363,6 +374,9 @@ class MemoryLedger:
if not reason:
return {"ok": False, "error": "reason_blank"}
ticket.lost_reason = reason
ticket.event_wait_kind = ""
ticket.event_wait_started_at = ""
ticket.event_wait_alerted = 0
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
@@ -940,6 +954,11 @@ class MemoryLedger:
continue
if ticket.status in {"已成交", "已关闭", "转人工"}:
continue
# 未成交已写原因、协商中仍排着再问:不进巡检列表
if ticket.status == "未成交" and (ticket.lost_reason or "").strip():
continue
if ticket.status == "协商中" and (ticket.next_followup_at or "").strip():
continue
watchers: list[dict[str, Any]] = []
seen: set[str] = set()
sales_id = (ticket.sales_wecom_id or "").strip()
@@ -989,6 +1008,10 @@ class MemoryLedger:
"collab_member_ids": list(ticket.collab_member_ids),
"facts": dict(ticket.facts),
"eventException": ticket.event_exception,
"lost_reason": ticket.lost_reason,
"lostReason": ticket.lost_reason,
"next_followup_at": ticket.next_followup_at,
"nextFollowupAt": ticket.next_followup_at,
"watchers": watchers,
}
)
+199 -13
View File
@@ -222,6 +222,9 @@ class TestPoll(unittest.TestCase):
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.deal_followup import SHANGHAI
# 钉在周五,避免周日跑单测时工作日折算为 0
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
spec = importlib.util.spec_from_file_location("ee_poll", path)
self.assertIsNotNone(spec)
@@ -248,9 +251,7 @@ class TestPoll(unittest.TestCase):
t = book.get_ticket(work_order_no=wo)
t.collab_chat_id = "wr1"
t.collab_member_ids = ["p1"]
t.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
"%Y-%m-%d %H:%M:%S"
)
t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
book._timeouts["sea_product_other"] = 0.25
groups: list[dict] = []
@@ -258,7 +259,6 @@ class TestPoll(unittest.TestCase):
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
return True
now = datetime.now(SHANGHAI)
n = poll_event_exceptions(
ledger=book, now=now, send_group=send_group, notify_ready=False
)
@@ -305,13 +305,12 @@ class TestPoll(unittest.TestCase):
)
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
wo = created["work_order_no"]
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
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"
)
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
book._timeouts["air_route"] = 0.25
groups: list[dict] = []
@@ -321,7 +320,7 @@ class TestPoll(unittest.TestCase):
n = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
now=now,
send_group=send_group,
notify_ready=False,
)
@@ -355,13 +354,12 @@ class TestPoll(unittest.TestCase):
{"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"},
]
)
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
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"
)
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
book._timeouts["air_sales"] = 0.25
private: list[dict] = []
@@ -374,7 +372,7 @@ class TestPoll(unittest.TestCase):
n = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
now=now,
enqueue=enqueue,
send_group=send_group,
notify_ready=False,
@@ -402,7 +400,7 @@ class TestPoll(unittest.TestCase):
n_deal = mod.poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
now=now,
enqueue=enqueue,
send_group=send_deal_group,
notify_ready=False,
@@ -429,5 +427,193 @@ class TestMentionsForRole(unittest.TestCase):
self.assertEqual([row["name"] for row in rows], ["吴季林"])
class TestDealOutcomeStopsTimeout(unittest.TestCase):
"""对应现网截图:协商中顺延 / 未成交已写原因后不应再发销售响应超时。"""
def _poll_mod(self):
import importlib.util
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
spec = importlib.util.spec_from_file_location("ee_poll_deal_stop", path)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _book_with_deal_wait(self):
from datetime import datetime, timedelta
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.deal_followup import SHANGHAI
book = MemoryLedger()
book.set_sea_staff(
[
{
"wecomId": "sales1",
"name": "罗小华",
"roleCode": "sales",
"status": "active",
}
]
)
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
wo = created["work_order_no"]
book.transition(work_order_no=wo, to_status="已报价")
book.start_event_wait(work_order_no=wo, wait_kind="deal")
ticket = book.get_ticket(work_order_no=wo)
ticket.collab_chat_id = "wr-deal"
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=72)).strftime(
"%Y-%m-%d %H:%M:%S"
)
book._timeouts["air_sales"] = 48
return book, wo
def test_negotiate_clears_wait_no_timeout(self) -> None:
from datetime import datetime
from agent.policy.deal_followup import SHANGHAI
from agent.policy.deal_outcome import finish_deal_outcome
book, wo = self._book_with_deal_wait()
result = finish_deal_outcome(
ledger=book,
work_order_no=wo,
outcome="协商中",
facts={},
quote={"total": "100"},
business_line="AIR",
sales_name="罗小华",
notify_ready=False,
)
self.assertTrue(result.get("ok"))
ticket = book.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "协商中")
self.assertEqual(ticket.event_wait_kind, "")
groups: list[dict] = []
n = self._poll_mod().poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
send_group=lambda **kwargs: groups.append(kwargs) or True,
notify_ready=False,
)
self.assertEqual(n, 0)
self.assertEqual(groups, [])
def test_lost_with_reason_clears_wait_no_timeout(self) -> None:
from datetime import datetime
from agent.policy.deal_followup import SHANGHAI
from agent.policy.deal_outcome import finish_deal_outcome
book, wo = self._book_with_deal_wait()
result = finish_deal_outcome(
ledger=book,
work_order_no=wo,
outcome="未成交",
facts={},
quote={"total": "100"},
business_line="AIR",
sales_name="罗小华",
lost_reason="客户不要了",
notify_ready=False,
)
self.assertTrue(result.get("ok"))
self.assertEqual(result.get("phase"), "done")
ticket = book.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "客户不要了")
self.assertEqual(ticket.event_wait_kind, "")
groups: list[dict] = []
n = self._poll_mod().poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
send_group=lambda **kwargs: groups.append(kwargs) or True,
notify_ready=False,
)
self.assertEqual(n, 0)
self.assertEqual(groups, [])
def test_stale_deal_wait_during_negotiate_is_skipped(self) -> None:
"""截图回归:协商中已顺延,即使库里仍留着陈旧 deal 等待,也不再发超时。"""
from datetime import datetime
from agent.policy.deal_followup import SHANGHAI
from agent.policy.deal_outcome import finish_deal_outcome
book, wo = self._book_with_deal_wait()
ticket = book.get_ticket(work_order_no=wo)
started = ticket.event_wait_started_at
result = finish_deal_outcome(
ledger=book,
work_order_no=wo,
outcome="协商中",
facts={},
quote={"total": "100"},
business_line="AIR",
sales_name="罗小华",
notify_ready=False,
)
self.assertTrue(result.get("ok"))
ticket = book.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "协商中")
self.assertTrue((ticket.next_followup_at or "").strip())
# 人为留下陈旧等待(模拟 clear/迁态未清干净),巡检必须跳过
ticket.event_wait_kind = "deal"
ticket.event_wait_started_at = started
ticket.event_wait_alerted = 0
groups: list[dict] = []
n = self._poll_mod().poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
send_group=lambda **kwargs: groups.append(kwargs) or True,
notify_ready=False,
)
self.assertEqual(n, 0)
self.assertEqual(groups, [])
def test_stale_deal_wait_after_lost_reason_is_skipped(self) -> None:
"""截图回归:未成交原因已记,陈旧 deal 等待不再触发销售超时。"""
from datetime import datetime
from agent.policy.deal_followup import SHANGHAI
from agent.policy.deal_outcome import finish_deal_outcome
book, wo = self._book_with_deal_wait()
ticket = book.get_ticket(work_order_no=wo)
started = ticket.event_wait_started_at
result = finish_deal_outcome(
ledger=book,
work_order_no=wo,
outcome="未成交",
facts={},
quote={"total": "100"},
business_line="AIR",
sales_name="罗小华",
lost_reason="客户不要了",
notify_ready=False,
)
self.assertTrue(result.get("ok"))
ticket = book.get_ticket(work_order_no=wo)
self.assertEqual(ticket.lost_reason, "客户不要了")
ticket.event_wait_kind = "deal"
ticket.event_wait_started_at = started
ticket.event_wait_alerted = 0
groups: list[dict] = []
n = self._poll_mod().poll_event_exceptions(
ledger=book,
now=datetime.now(SHANGHAI),
send_group=lambda **kwargs: groups.append(kwargs) or True,
notify_ready=False,
)
self.assertEqual(n, 0)
self.assertEqual(groups, [])
if __name__ == "__main__":
unittest.main()