多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。 Co-authored-by: Cursor <cursoragent@cursor.com>
545 lines
21 KiB
Python
545 lines
21 KiB
Python
"""多段群内:按岗位归段报价,全部出单才整票成交跟进。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
if ROOT not in sys.path:
|
|
sys.path.insert(0, ROOT)
|
|
|
|
from unittest.mock import patch
|
|
|
|
from agent.channel.wecom.models import InboundMessage
|
|
from agent.handlers.group_collab import handle_group_inbound
|
|
from agent.ledger.memory_ledger import MemoryLedger
|
|
from agent.policy.multi_group_ops import deal_text, handle_multi_inbound
|
|
from agent.policy.multi_segments import Segment
|
|
|
|
|
|
class _Client:
|
|
def __init__(self) -> None:
|
|
self.texts: list[str] = []
|
|
self.cards: list[dict] = []
|
|
self.files: list[str] = []
|
|
|
|
def send_group(self, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
|
|
_ = chat_id, mention_userids
|
|
self.texts.append(content)
|
|
return {"ok": True}
|
|
|
|
def send_group_card(self, chat_id: str, template_card: dict) -> dict:
|
|
_ = chat_id
|
|
self.cards.append(template_card)
|
|
return {"ok": True}
|
|
|
|
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
|
|
_ = chat_id, filepath, file_b64
|
|
self.files.append(filename)
|
|
return {"ok": True}
|
|
|
|
|
|
class _Flow:
|
|
def __init__(self, ledger: MemoryLedger, client: _Client) -> None:
|
|
self.ledger = ledger
|
|
self._ledger = ledger
|
|
self._group_client = client
|
|
|
|
def group_client(self) -> _Client:
|
|
return self._group_client
|
|
|
|
|
|
def _ticket(ledger: MemoryLedger) -> object:
|
|
created = ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
|
|
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
ledger.set_sea_staff(
|
|
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
|
|
)
|
|
ledger.set_land_staff(
|
|
[{"wecomId": "land1", "name": "陆产", "roleCode": "land", "status": "active", "routes": "国内长途/零担"}]
|
|
)
|
|
ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
|
|
no = created["work_order_no"]
|
|
ledger.bind_collab_group(
|
|
work_order_no=no,
|
|
chat_id="room-1",
|
|
member_ids=["sales", "sea1", "land1"],
|
|
product_names=["海产", "陆产"],
|
|
product_ids=["sea1", "land1"],
|
|
)
|
|
return ledger.get_ticket(work_order_no=no)
|
|
|
|
|
|
class QuoteTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.ledger = MemoryLedger()
|
|
self.client = _Client()
|
|
self.flow = _Flow(self.ledger, self.client)
|
|
self.ticket = _ticket(self.ledger)
|
|
self.sheets: list[str] = []
|
|
|
|
def sheet(self, **kwargs: object) -> dict:
|
|
self.sheets.append(str(kwargs.get("business_line") or ""))
|
|
return {"ok": True}
|
|
|
|
def test_one_segment_quote_does_not_open_deal(self) -> None:
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ticket,
|
|
chat_id="room-1",
|
|
sender_id="sea1",
|
|
text="海运费 100CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "segment_quoted")
|
|
self.assertEqual(self.sheets, ["SEA"])
|
|
self.assertFalse(self.client.cards)
|
|
self.assertTrue(any("其他段报完" in text for text in self.client.texts))
|
|
|
|
def test_both_quotes_one_deal_without_sum(self) -> None:
|
|
handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ledger.get_ticket(work_order_no=self.ticket.work_order_no),
|
|
chat_id="room-1",
|
|
sender_id="sea1",
|
|
text="海运费 100CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ledger.get_ticket(work_order_no=self.ticket.work_order_no),
|
|
chat_id="room-1",
|
|
sender_id="land1",
|
|
text="陆运费 80CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_deal_sent")
|
|
self.assertEqual(self.sheets, ["SEA", "LAND"])
|
|
body = self.client.texts[-1]
|
|
self.assertIn("段1:海运", body)
|
|
self.assertIn("段2:陆运", body)
|
|
self.assertNotIn("180", body)
|
|
rows = self.client.cards[-1]["horizontal_content_list"]
|
|
self.assertEqual([row["keyname"] for row in rows], ["段1海运", "段2陆运"])
|
|
joined = " ".join(row["value"] for row in rows)
|
|
self.assertNotIn("180", joined)
|
|
|
|
def test_land_person_cannot_fill_sea_segment(self) -> None:
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ticket,
|
|
chat_id="room-1",
|
|
sender_id="land1",
|
|
text="海运费 100CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "segment_quoted")
|
|
self.assertEqual(self.sheets, ["LAND"])
|
|
|
|
def test_requote_refreshes_deal(self) -> None:
|
|
handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ticket,
|
|
chat_id="room-1",
|
|
sender_id="sea1",
|
|
text="海运费 100CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ledger.get_ticket(work_order_no=self.ticket.work_order_no),
|
|
chat_id="room-1",
|
|
sender_id="land1",
|
|
text="陆运费 80CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ledger.get_ticket(work_order_no=self.ticket.work_order_no),
|
|
chat_id="room-1",
|
|
sender_id="sea1",
|
|
text="海运费 120CNY",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_deal_sent")
|
|
self.assertIn("120", self.client.texts[-1])
|
|
self.assertNotIn("100", deal_text(
|
|
work_order_no=self.ticket.work_order_no,
|
|
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
|
quotes={"SEA": {"total": "120", "currency": "CNY"}, "LAND": {"total": "80", "currency": "CNY"}},
|
|
))
|
|
|
|
def test_work_order_in_group_sends_brief_and_templates(self) -> None:
|
|
no = self.ticket.work_order_no
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=self.ticket,
|
|
chat_id="room-9",
|
|
sender_id="sales",
|
|
text=no,
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_activated")
|
|
self.assertEqual(self.sheets, ["SEA", "LAND"])
|
|
brief = self.client.texts[-1]
|
|
self.assertIn("段1:海运", brief)
|
|
self.assertIn("段2:陆运", brief)
|
|
self.assertNotIn("TMS报价", brief)
|
|
self.assertNotIn("其他段报完", brief)
|
|
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
|
|
|
|
def test_at_bot_work_order_activates_not_quote(self) -> None:
|
|
self.ledger.upsert_staff(
|
|
{"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"}
|
|
)
|
|
created = self.ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{
|
|
"index": 1,
|
|
"mode": "LAND",
|
|
"facts": {
|
|
"运输类型": "国内运输拼车",
|
|
"起运港": "广州",
|
|
"目的港": "深圳",
|
|
"品名": "LMM振动测试定位治具",
|
|
"体积": "100CBM",
|
|
"毛重": "1.5kg",
|
|
},
|
|
},
|
|
{
|
|
"index": 2,
|
|
"mode": "AIR",
|
|
"facts": {
|
|
"目的港": "CRK",
|
|
"品名": "LMM振动测试定位治具",
|
|
"件数": "1件",
|
|
"毛重": "1.5",
|
|
"体积": "100CBM",
|
|
"包装方式": "托盘",
|
|
"起运港": "ZUH",
|
|
"贸易条款": "DAP",
|
|
},
|
|
},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
no = created["work_order_no"]
|
|
ticket = self.ledger.get_ticket(work_order_no=no)
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=ticket,
|
|
chat_id="air-room",
|
|
sender_id="sales",
|
|
text=f"@询价小助手 {no}",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_activated")
|
|
self.assertEqual(self.sheets, ["LAND", "AIR"])
|
|
brief = self.client.texts[0]
|
|
self.assertIn("@销售甲", brief)
|
|
self.assertIn("@陆产", brief)
|
|
self.assertIn("@航线甲", brief)
|
|
self.assertIn("段1:陆运", brief)
|
|
self.assertIn("运输类型(必填):国内运输拼车", brief)
|
|
self.assertIn("始发站(必填):广州", brief)
|
|
self.assertIn("段2:空运", brief)
|
|
self.assertIn("目的地(必填):CRK", brief)
|
|
self.assertIn("贸易条款(非必填):DAP", brief)
|
|
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
|
self.assertNotIn("询价字段:", brief)
|
|
|
|
def test_http_snapshot_activation_mentions_real_sales_not_job_word(self) -> None:
|
|
"""主账快照没带回销售企微号时,群里仍要 @ 具体人,不能写岗位词「销售」。"""
|
|
self.ledger.upsert_staff(
|
|
{"wecomId": "WuJiLin", "name": "吴吉林", "roleCode": "sales", "status": "active"}
|
|
)
|
|
self.ledger.upsert_staff(
|
|
{"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"}
|
|
)
|
|
created = self.ledger.create_ticket(
|
|
sender_id="WuJiLin",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{
|
|
"index": 1,
|
|
"mode": "LAND",
|
|
"facts": {
|
|
"运输类型": "国内运输拼车",
|
|
"起运港": "广州",
|
|
"目的港": "深圳",
|
|
"品名": "治具",
|
|
},
|
|
},
|
|
{"index": 2, "mode": "AIR", "facts": {"起运港": "ZUH", "目的港": "CRK", "品名": "治具"}},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
no = created["work_order_no"]
|
|
real_get = self.ledger.get_ticket
|
|
|
|
def http_snap(*, work_order_no: str):
|
|
ticket = real_get(work_order_no=work_order_no)
|
|
if ticket is None:
|
|
return None
|
|
return {
|
|
"ok": True,
|
|
"found": True,
|
|
"work_order_no": ticket.work_order_no,
|
|
"business_line": "MULTI",
|
|
"facts": dict(ticket.facts),
|
|
"sales_name": "销售",
|
|
"salesName": "销售",
|
|
"collab_chat_id": ticket.collab_chat_id,
|
|
}
|
|
|
|
self.ledger.get_ticket = http_snap # type: ignore[method-assign]
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=http_snap(work_order_no=no),
|
|
chat_id="live-room",
|
|
sender_id="WuJiLin",
|
|
text=f"@询价小助手 {no}",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_activated")
|
|
brief = self.client.texts[0]
|
|
self.assertIn("@吴吉林", brief)
|
|
self.assertNotRegex(brief, r"@销售(?:@|\n|$)")
|
|
|
|
def test_land_sheet_matches_when_route_missing_and_no_default(self) -> None:
|
|
"""多段陆运常缺线路类别;后台陆运模板只按线路关键词时,仍要发出陆运表。"""
|
|
self.ledger.set_quote_templates(
|
|
[
|
|
{
|
|
"templateId": "air-default",
|
|
"name": "空运默认",
|
|
"bizType": "空运",
|
|
"keywords": [],
|
|
"isDefault": True,
|
|
},
|
|
{
|
|
"templateId": "land-lcl",
|
|
"name": "陆运国内运输拼车报价单",
|
|
"bizType": "陆运",
|
|
"keywords": ["国内拼车", "国内零担"],
|
|
"isDefault": False,
|
|
},
|
|
]
|
|
)
|
|
created = self.ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{
|
|
"index": 1,
|
|
"mode": "LAND",
|
|
"facts": {
|
|
"运输类型": "国内运输拼车",
|
|
"起运港": "广州",
|
|
"目的港": "深圳",
|
|
"品名": "治具",
|
|
"体积": "100CBM",
|
|
"毛重": "1.5kg",
|
|
},
|
|
},
|
|
{
|
|
"index": 2,
|
|
"mode": "AIR",
|
|
"facts": {"起运港": "ZUH", "目的港": "CRK", "品名": "治具"},
|
|
},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
no = created["work_order_no"]
|
|
ticket = self.ledger.get_ticket(work_order_no=no)
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=ticket,
|
|
chat_id="sheet-room",
|
|
sender_id="sales",
|
|
text=f"@询价小助手 {no}",
|
|
)
|
|
self.assertEqual(phase, "multi_activated")
|
|
self.assertEqual(len(self.client.files), 2)
|
|
self.assertTrue(self.ledger.last_render.get("ok"))
|
|
|
|
def test_lock_on_air_segment_says_multi_unsupported(self) -> None:
|
|
"""多段含空运时航线锁舱,原因写成暂不支持,不再说不是空运单。"""
|
|
created = self.ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港"}},
|
|
{"index": 2, "mode": "AIR", "facts": {"目的港": "CRK"}},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
|
|
no = created["work_order_no"]
|
|
ticket = self.ledger.get_ticket(work_order_no=no)
|
|
phase = handle_multi_inbound(
|
|
flow=self.flow,
|
|
ticket=ticket,
|
|
chat_id="air-room",
|
|
sender_id="air1",
|
|
text=f"{no}锁舱",
|
|
sheet_fn=self.sheet,
|
|
)
|
|
self.assertEqual(phase, "multi_lock_unsupported")
|
|
joined = "\n".join(self.client.texts)
|
|
self.assertIn(f"工单{no} 空运锁舱失败。", joined)
|
|
self.assertIn("操作人:航线甲", joined)
|
|
self.assertIn("锁舱失败的原因:多段询价,暂时不支持锁舱", joined)
|
|
self.assertNotIn("不是空运单,不能锁舱", joined)
|
|
self.assertNotIn("已通知IT", joined)
|
|
self.assertNotIn("请稍等", joined)
|
|
self.assertFalse(any("其他段报完" in text for text in self.client.texts))
|
|
|
|
|
|
class _Bot:
|
|
"""替掉真实桥,记下这一次群里实际会发出去的字。"""
|
|
|
|
made: list["_Bot"] = []
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
_ = args, kwargs
|
|
self.texts: list[str] = []
|
|
_Bot.made.append(self)
|
|
|
|
def send_group(self, *, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
|
|
_ = chat_id, mention_userids
|
|
self.texts.append(content)
|
|
return {"ok": True}
|
|
|
|
def send_group_file(self, **kwargs: object) -> dict:
|
|
_ = kwargs
|
|
return {"ok": True}
|
|
|
|
|
|
class ArchiveSenderTests(unittest.TestCase):
|
|
"""会话存档听到的报价:含空运走机器人,海运陆运仍走应用自己建的群。"""
|
|
|
|
def setUp(self) -> None:
|
|
_Bot.made = []
|
|
self.ledger = MemoryLedger()
|
|
self.app = _Client()
|
|
self.flow = _Flow(self.ledger, self.app)
|
|
|
|
def _message(self, chat_id: str, sender_id: str, text: str) -> InboundMessage:
|
|
return InboundMessage(
|
|
sender_id=sender_id,
|
|
message_id="m1",
|
|
content=text,
|
|
chat_id=chat_id,
|
|
chat_type="group",
|
|
)
|
|
|
|
def test_air_multi_quote_goes_through_bot(self) -> None:
|
|
created = self.ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{"index": 1, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
|
|
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
|
|
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
|
|
no = created["work_order_no"]
|
|
self.ledger.bind_collab_group(
|
|
work_order_no=no,
|
|
chat_id="bot-room",
|
|
member_ids=["sales", "air1"],
|
|
product_names=["航线甲"],
|
|
product_ids=["air1"],
|
|
)
|
|
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
|
|
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
|
|
phase = handle_group_inbound(
|
|
self._message("bot-room", "air1", "空运费 9900CNY"),
|
|
flow=self.flow,
|
|
ledger=self.ledger,
|
|
)
|
|
self.assertEqual(phase, "segment_quoted")
|
|
self.assertFalse(self.app.texts)
|
|
self.assertTrue(_Bot.made)
|
|
self.assertTrue(any("报价单已发出" in text for text in _Bot.made[-1].texts))
|
|
|
|
def test_sea_land_quote_stays_on_app_client(self) -> None:
|
|
created = self.ledger.create_ticket(
|
|
sender_id="sales",
|
|
business_line="MULTI",
|
|
facts={
|
|
"segments_json": json.dumps(
|
|
[
|
|
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
|
|
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
},
|
|
)
|
|
self.ledger.set_sea_staff(
|
|
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
|
|
)
|
|
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
|
|
no = created["work_order_no"]
|
|
self.ledger.bind_collab_group(
|
|
work_order_no=no,
|
|
chat_id="app-room",
|
|
member_ids=["sales", "sea1"],
|
|
product_names=["海产"],
|
|
product_ids=["sea1"],
|
|
)
|
|
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
|
|
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
|
|
phase = handle_group_inbound(
|
|
self._message("app-room", "sea1", "海运费 100CNY"),
|
|
flow=self.flow,
|
|
ledger=self.ledger,
|
|
)
|
|
self.assertEqual(phase, "segment_quoted")
|
|
self.assertFalse(_Bot.made)
|
|
self.assertTrue(any("报价单已发出" in text for text in self.app.texts))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|