421 lines
17 KiB
Python
421 lines
17 KiB
Python
"""
|
|
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
|
|
"""
|
|
|
|
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")
|