"""多段群内:按岗位归段报价,全部出单才整票成交跟进。""" 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()