""" 成交三态:入账、推负责人、收下未成交原因、协商中不停会话。 """ from __future__ import annotations import unittest from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession from agent.policy.deal_outcome import ( capture_lost_reason, finish_deal_outcome, harvest_inline_lost_reason, ) from agent.policy.sea_text_flow import SeaTextInquiryFlow class TestDealOutcome(unittest.TestCase): def setUp(self) -> None: self.ledger = MemoryLedger() created = self.ledger.create_ticket( sender_id="sales1", business_line="SEA", facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"}, ) self.wo = str(created.get("work_order_no") or "") self.ledger.transition(work_order_no=self.wo, to_status="已报价") self.ledger.upsert_staff( { "name": "海运负责人", "wecomId": "lead-sea", "roleCode": "sea", "routes": "海运全线", "isLeader": 1, "status": "active", } ) def test_deal_notifies_leaders(self) -> None: queued: list[dict] = [] def enqueue(*, touser, content, dedupe_key, payload): queued.append( { "touser": touser, "content": content, "dedupe_key": dedupe_key, "payload": payload, } ) return True, "ok" out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="已成交", facts={"起运港": "上海", "目的港": "洛杉矶"}, quote={"total": "1000", "currency": "USD"}, business_line="SEA", sales_name="吴吉林", enqueue=enqueue, notify_ready=True, ) self.assertTrue(out.get("ok")) self.assertEqual(out.get("phase"), "done") self.assertIn("海运负责人", str(out.get("notify_summary") or "")) self.assertEqual(queued[0]["touser"], "lead-sea") self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify") ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "已成交") self.assertEqual(ticket.next_followup_at, "") self.assertEqual(ticket.event_wait_kind, "") def test_deal_notify_card_omits_sales_label(self) -> None: """成交通知不要「销售」二字:标题副标和正文都不写。""" text = copy.deal_notify_card( work_order_no="WO202609180008", sales_name="吴吉林", facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"}, quote={}, business_line="LAND", ) self.assertNotIn("销售", text) payload = copy.deal_notify_wecom_payload( work_order_no="WO202609180008", sales_name="吴吉林", facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"}, quote={}, business_line="LAND", ) title = (payload.get("template_card") or {}).get("main_title") or {} blob = str(title.get("title") or "") + str(title.get("desc") or "") self.assertNotIn("销售", blob) self.assertNotIn("desc", title) def test_deal_without_leader_still_closes(self) -> None: self.ledger.upsert_staff( { "name": "不是负责人", "wecomId": "lead-sea", "roleCode": "sea", "routes": "海运全线", "isLeader": 0, "status": "active", } ) out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="已成交", facts={}, quote={}, business_line="SEA", notify_ready=True, ) self.assertEqual(out.get("notify_summary"), "无匹配负责人") self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交") def test_lost_waits_for_reason(self) -> None: out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="未成交", facts={}, quote={}, business_line="SEA", ) self.assertEqual(out.get("phase"), "wait_lost_reason") ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "未成交") self.assertEqual(ticket.lost_reason, "") self.assertEqual(ticket.event_wait_kind, "lost_reason") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") self.assertEqual(view.get("wait_phase"), "wait_lost_reason") captured = capture_lost_reason( ledger=self.ledger, work_order_no=self.wo, reason="客人比价了" ) self.assertTrue(captured.get("ok")) ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.lost_reason, "客人比价了") self.assertEqual(ticket.event_wait_kind, "") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") self.assertEqual(view.get("wait_phase"), "done") def test_lost_from_inquiring_promotes_quoted_first(self) -> None: """主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。""" class _StrictSix: def __init__(self) -> None: self.status = "询价中" self.lost_reason = "" self.event_wait_kind = "" def transition(self, *, work_order_no, to_status, **kwargs): _ = work_order_no allowed = { "询价中": {"已报价", "协商中", "已关闭"}, "已报价": {"协商中", "已成交", "未成交", "已关闭"}, } if to_status not in allowed.get(self.status, set()): # 模拟 http_ledger 在 500 时只回 http_500(丢失正文) return {"ok": False, "error": "http_500"} self.status = to_status if to_status == "未成交" and kwargs.get("lost_reason"): self.lost_reason = str(kwargs.get("lost_reason") or "") return {"ok": True, "status": self.status} def patch_lost_reason(self, *, work_order_no, lost_reason): _ = work_order_no self.lost_reason = lost_reason return {"ok": True} def get_for_agent(self, *, work_order_no, sender_id): _ = (work_order_no, sender_id) return {"status": self.status, "lost_reason": self.lost_reason} ledger = _StrictSix() out = finish_deal_outcome( ledger=ledger, work_order_no="WO202610070023", outcome="未成交", facts={}, quote={}, business_line="AIR", ) self.assertTrue(out.get("ok"), out) self.assertEqual(ledger.status, "未成交") self.assertEqual(out.get("phase"), "wait_lost_reason") def test_lost_reason_ack_repeats_user_text(self) -> None: self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") def test_lost_reason_ack_fills_ticket_no(self) -> None: """后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。""" from agent.policy.bot_reply import override_catalog override_catalog( { "lost.reason_ack": ( "已将未成交原因“{{lost_reason}}”记录在后台," "工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。" ) } ) try: self.assertEqual( copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"), "已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。", ) finally: override_catalog(None) def test_harvest_inline_lost_reason(self) -> None: self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual( harvest_inline_lost_reason("WO202609220014@询价小助手 未成交,原因客户不想送货了"), "客户不想送货了", ) self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了") self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高") self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了") self.assertEqual( harvest_inline_lost_reason("WO202609240010未成交,原因客户不需要了@询价小助手"), "客户不需要了", ) self.assertEqual(harvest_inline_lost_reason("未成交"), "") self.assertEqual(harvest_inline_lost_reason("未成交吗"), "") self.assertEqual(harvest_inline_lost_reason("成交"), "") def test_lost_with_inline_reason_finishes(self) -> None: out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="未成交", facts={}, quote={}, business_line="SEA", lost_reason="客户不想要了", ) self.assertEqual(out.get("phase"), "done") ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "未成交") self.assertEqual(ticket.lost_reason, "客户不想要了") self.assertEqual(ticket.event_wait_kind, "") self.assertEqual( copy.deal_ack("未成交", self.wo, lost_reason="客户不想要了"), "已记录为「未成交」。已记下未成交原因:客户不想要了。", ) def test_private_lost_and_reason_same_message(self) -> None: flow = AirTextInquiryFlow(self.ledger) flow._save( FlowSession( sender_id="sales1", thread_id="sales1", phase="wait_deal", business_line="SEA", work_order_no=self.wo, allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE), ) ) replies: list[str] = [] phase = flow.on_text( sender_id="sales1", text="未成交,原因客户不想要了", reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "done") self.assertEqual( self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客户不想要了" ) joined = "\n".join(replies) self.assertIn("客户不想要了", joined) self.assertNotIn("请补充未成交原因", joined) def test_negotiate_ack_names_followup_day(self) -> None: text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00") self.assertEqual( text, "已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。", ) self.assertNotIn("跟进周期已顺延", text) def test_negotiate_keeps_wait_deal(self) -> None: self.ledger.start_event_wait(work_order_no=self.wo, wait_kind="deal") out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="协商中", facts={}, quote={}, business_line="SEA", ) self.assertEqual(out.get("phase"), "wait_deal") ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "协商中") self.assertEqual(ticket.event_wait_kind, "") self.assertTrue(ticket.next_followup_at) view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") self.assertEqual(view.get("wait_phase"), "wait_deal") again = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="协商中", facts={}, quote={}, business_line="SEA", ) self.assertEqual(again.get("phase"), "wait_deal") self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中") self.assertTrue(again.get("next_followup_at")) def test_private_next_text_is_lost_reason(self) -> None: flow = AirTextInquiryFlow(self.ledger) sess = FlowSession( sender_id="sales1", thread_id="sales1", phase="wait_deal", business_line="SEA", work_order_no=self.wo, allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE), ) flow._save(sess) replies: list[str] = [] phase = flow.on_button( sender_id="sales1", action=copy.BTN_LOST, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "wait_lost_reason") phase = flow.on_text( sender_id="sales1", text="价格高", reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "done") self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高") self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies)) def test_sea_private_next_text_is_lost_reason(self) -> None: flow = SeaTextInquiryFlow(self.ledger) flow._save( FlowSession( sender_id="sales1", thread_id="sales1", phase="wait_deal", business_line="SEA", work_order_no=self.wo, allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE), ) ) replies: list[str] = [] phase = flow.on_button( sender_id="sales1", action=copy.BTN_LOST, reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "wait_lost_reason") phase = flow.on_text( sender_id="sales1", text="客人比价了", reply=lambda t, extra=None: replies.append(t), ) self.assertEqual(phase, "done") self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了") def test_claim_due_negotiate_clears_schedule(self) -> None: finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, outcome="协商中", facts={}, quote={}, business_line="SEA", ) ticket = self.ledger.get_ticket(work_order_no=self.wo) ticket.next_followup_at = "2020-01-01 09:00:00" rows = self.ledger.claim_due_negotiate() self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["work_order_no"], self.wo) self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "") def test_reask_starts_deal_wait(self) -> None: """协商再问发出后,重新盯销售成交跟进。""" import importlib.util import os path = os.path.join( os.path.dirname(__file__), "..", "agent", "jobs", "deal_followup_poll.py" ) spec = importlib.util.spec_from_file_location("deal_followup_poll_ee", path) self.assertIsNotNone(spec) self.assertIsNotNone(spec.loader) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) class Cfg: wecom_corp_id = "" wecom_secret = "" wecom_agent_id = "0" mod._send_group_reask = lambda **kwargs: True ok = mod._reask_one( { "work_order_no": self.wo, "collab_chat_id": "wr1", "sales_name": "吴吉林", "facts": {}, "quote": {}, "business_line": "SEA", }, settings=Cfg(), ledger=self.ledger, ) self.assertTrue(ok) ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.event_wait_kind, "deal")