落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
空运 BOT 出站:没有当场回话地址时,打桥主动往群里说。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.channel.aibot import normalize_aibot_payload
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
|
||||
class AibotInboundNormalizeTests(unittest.TestCase):
|
||||
def test_official_wecom_body(self) -> None:
|
||||
parsed = normalize_aibot_payload(
|
||||
{
|
||||
"msgid": "m1",
|
||||
"chatid": "wr_air_1",
|
||||
"chattype": "group",
|
||||
"from": {"userid": "WuJiLin"},
|
||||
"msgtype": "text",
|
||||
"text": {"content": "@询价小助手 WO202609160009"},
|
||||
"aibot_direct": True,
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.sender_id, "WuJiLin")
|
||||
self.assertEqual(parsed.message_id, "m1")
|
||||
self.assertEqual(parsed.chat_id, "wr_air_1")
|
||||
self.assertEqual(parsed.chat_type, "group")
|
||||
self.assertIn("WO202609160009", parsed.content)
|
||||
self.assertEqual(parsed.raw.get("source"), "aibot")
|
||||
|
||||
|
||||
class AibotReplyTests(unittest.TestCase):
|
||||
def test_empty_content_skips(self) -> None:
|
||||
client = AibotReplyClient("")
|
||||
out = client.send_group(chat_id="wr_1", content=" ")
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertEqual(out["error"], "empty")
|
||||
|
||||
def test_proactive_posts_send_group(self) -> None:
|
||||
client = AibotReplyClient("")
|
||||
fake_http = MagicMock()
|
||||
fake_http.post.return_value.status_code = 200
|
||||
fake_http.post.return_value.text = '{"status":"sent"}'
|
||||
settings = MagicMock()
|
||||
settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913"
|
||||
settings.wecom_aibot_bridge_token = "tok"
|
||||
with patch("agent.channel.aibot.reply.httpx", fake_http), patch(
|
||||
"agent.channel.aibot.reply.get_settings", return_value=settings
|
||||
):
|
||||
out = client.send_group(chat_id="wr_air_1", content="附件已保存")
|
||||
self.assertTrue(out["ok"])
|
||||
url, = fake_http.post.call_args.args
|
||||
self.assertEqual(url, "http://127.0.0.1:8913/send")
|
||||
payload = fake_http.post.call_args.kwargs["json"]
|
||||
self.assertEqual(payload["chatid"], "wr_air_1")
|
||||
self.assertEqual(payload["operation"], "text")
|
||||
self.assertEqual(payload["content"], "附件已保存")
|
||||
self.assertEqual(
|
||||
fake_http.post.call_args.kwargs["headers"]["X-Aibot-Bridge-Token"],
|
||||
"tok",
|
||||
)
|
||||
|
||||
def test_send_group_file_posts_send_file(self) -> None:
|
||||
client = AibotReplyClient("")
|
||||
fake_http = MagicMock()
|
||||
fake_http.post.return_value.status_code = 200
|
||||
fake_http.post.return_value.text = '{"status":"sent"}'
|
||||
settings = MagicMock()
|
||||
settings.wecom_aibot_bridge_url = "http://127.0.0.1:8913"
|
||||
settings.wecom_aibot_bridge_token = "tok"
|
||||
with patch("agent.channel.aibot.reply.httpx", fake_http), patch(
|
||||
"agent.channel.aibot.reply.get_settings", return_value=settings
|
||||
):
|
||||
out = client.send_group_file(
|
||||
chat_id="wr_air_1",
|
||||
filename="WO1_quote.xlsx",
|
||||
file_b64="QQ==",
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
url, = fake_http.post.call_args.args
|
||||
self.assertEqual(url, "http://127.0.0.1:8913/send-file")
|
||||
payload = fake_http.post.call_args.kwargs["json"]
|
||||
self.assertEqual(payload["chatid"], "wr_air_1")
|
||||
self.assertEqual(payload["filename"], "WO1_quote.xlsx")
|
||||
|
||||
def test_stream_url_does_not_hit_bridge(self) -> None:
|
||||
client = AibotReplyClient("https://example.com/reply")
|
||||
fake_http = MagicMock()
|
||||
fake_http.post.return_value.status_code = 200
|
||||
with patch("agent.channel.aibot.reply.httpx", fake_http):
|
||||
out = client.send_group(chat_id="wr_air_1", content="已激活")
|
||||
self.assertTrue(out["ok"])
|
||||
url, = fake_http.post.call_args.args
|
||||
self.assertEqual(url, "https://example.com/reply")
|
||||
@@ -0,0 +1,853 @@
|
||||
"""
|
||||
空运群内协同:激活当前工单、补字段、航线报价、锁舱/释放、成交。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.air_group_collab import handle_air_group
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_LOCK,
|
||||
INTENT_RELEASE,
|
||||
classify_air_group_text,
|
||||
)
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
|
||||
class FakeBotGroup:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict] = []
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str, mention_userids=None):
|
||||
self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"})
|
||||
|
||||
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = ""):
|
||||
self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class AirGroupCollabTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.air_inbound import reset_air_inbound_for_tests
|
||||
|
||||
reset_air_inbound_for_tests()
|
||||
self.ledger = MemoryLedger()
|
||||
self.groups = FakeBotGroup()
|
||||
self.flow = AirTextInquiryFlow(ledger=self.ledger, group_client=self.groups)
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "WuJiLin",
|
||||
"wecomId": "sales1",
|
||||
"roleCode": "sales",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "HangXian",
|
||||
"wecomId": "air1",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "air-default",
|
||||
"name": "空运报价",
|
||||
"bizType": "空运",
|
||||
"isDefault": True,
|
||||
},
|
||||
{
|
||||
"templateId": "sea-default",
|
||||
"name": "海运报价",
|
||||
"bizType": "海运",
|
||||
"isDefault": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def _create_air(self, *, sender="sales1", quote=None, status="已报价") -> str:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id=sender,
|
||||
business_line="AIR",
|
||||
facts={
|
||||
"起运地": "上海",
|
||||
"目的地": "洛杉矶",
|
||||
"品名": "配件",
|
||||
"件数": "3",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装类型": "纸箱",
|
||||
"报价日期": "2026-09-16",
|
||||
},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
if quote is None:
|
||||
quote = {
|
||||
"total": "USD 800",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS 标准报价",
|
||||
"quoteId": "QT-AIR-1",
|
||||
"airOptions": [
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"}
|
||||
],
|
||||
}
|
||||
if quote:
|
||||
self.ledger.upsert_quote(work_order_no=wo, quote=quote, to_status=status)
|
||||
return wo
|
||||
|
||||
def _msg(self, *, sender: str, text: str, chat: str, mid: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
sender_id=sender,
|
||||
message_id=mid,
|
||||
content=text,
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
raw={"source": "aibot"},
|
||||
)
|
||||
|
||||
def _texts(self) -> list[str]:
|
||||
return [x.get("content") or "" for x in self.groups.sent]
|
||||
|
||||
def test_intent_lock_is_not_hardcoded(self) -> None:
|
||||
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
|
||||
|
||||
def test_air_group_brief_matches_sea_sections(self) -> None:
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202609160009",
|
||||
facts={
|
||||
"起运地": "ZUH",
|
||||
"目的地": "广州",
|
||||
"品名": "服务器",
|
||||
"件数": "1",
|
||||
"毛重": "10",
|
||||
"体积": "1",
|
||||
"包装类型": "纸箱",
|
||||
"报价日期": "2026-09-16",
|
||||
},
|
||||
quote=None,
|
||||
collab_facts={},
|
||||
has_price=False,
|
||||
)
|
||||
self.assertIn("工单号:WO202609160009", text)
|
||||
self.assertIn("询价字段:\n运输方式:空运", text)
|
||||
self.assertIn("起运地:ZUH", text)
|
||||
self.assertIn("\n\n协同补充字段:\n是否含电:-", text)
|
||||
self.assertIn("是否含磁:-", text)
|
||||
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertNotIn("当前工单:", text)
|
||||
self.assertNotIn("贸易条款", text)
|
||||
|
||||
def test_work_order_only_is_activate_not_quote(self) -> None:
|
||||
from agent.routing.air_group_intent import INTENT_ACTIVATE
|
||||
|
||||
self.assertEqual(
|
||||
classify_air_group_text("WO202609160002", allow_network=False),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"agent.routing.air_group_intent._classify_with_model",
|
||||
side_effect=AssertionError("纯工单号不应再打模型"),
|
||||
):
|
||||
self.assertEqual(
|
||||
classify_air_group_text(
|
||||
"@询价小助手 WO202609160002",
|
||||
allow_network=True,
|
||||
),
|
||||
INTENT_ACTIVATE,
|
||||
)
|
||||
|
||||
def test_dispatch_aibot_goes_air(self) -> None:
|
||||
from agent.routing.dispatch import dispatch_inbound as _disp
|
||||
|
||||
wo = self._create_air()
|
||||
msg = self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="d1")
|
||||
# 单测注入 flow,避免打到进程单例
|
||||
import agent.handlers.air_group_collab as air_mod
|
||||
orig = air_mod.get_air_text_flow
|
||||
|
||||
def _fake():
|
||||
return self.flow
|
||||
|
||||
air_mod.get_air_text_flow = _fake # type: ignore
|
||||
try:
|
||||
name = _disp(msg, RouteDecision(intent="ordinary_text_other"))
|
||||
finally:
|
||||
air_mod.get_air_text_flow = orig # type: ignore
|
||||
self.assertEqual(name, "air_group_collab")
|
||||
|
||||
def test_activate_and_switch(self) -> None:
|
||||
wo1 = self._create_air()
|
||||
wo2 = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo1, chat="wr_air_1", mid="a1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单号:{wo1}", joined)
|
||||
self.assertIn("询价字段:", joined)
|
||||
self.assertIn("运输方式:空运", joined)
|
||||
self.assertIn("协同补充字段:", joined)
|
||||
self.assertIn("TMS报价:", joined)
|
||||
self.assertNotIn("当前工单:", joined)
|
||||
self.assertNotIn("询价要点:", joined)
|
||||
self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts()))
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
kinds = [x.get("kind") for x in self.groups.sent]
|
||||
self.assertLess(kinds.index("text"), kinds.index("file"))
|
||||
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=wo2, chat="wr_air_1", mid="a2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated")
|
||||
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2)
|
||||
|
||||
def test_second_group_occupied(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="o1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_2", mid="o2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "occupied")
|
||||
self.assertTrue(any("另一个群" in t for t in self._texts()))
|
||||
|
||||
def test_need_work_order_when_empty(self) -> None:
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="空运费100USD", chat="wr_air_1", mid="n1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "need_work_order")
|
||||
|
||||
def test_sales_can_fill_battery(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="f0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="f1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="collab_fields",
|
||||
)
|
||||
self.assertEqual(phase, "fields_saved")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("是否含电"), "是")
|
||||
self.assertEqual(ticket.collab_facts.get("是否含磁"), "否")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("工单号:", joined)
|
||||
self.assertIn("询价字段:", joined)
|
||||
self.assertIn("是否含电:是", joined)
|
||||
self.assertIn("是否含磁:否", joined)
|
||||
self.assertNotIn("已记下", joined)
|
||||
self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts()))
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {})
|
||||
self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "是")
|
||||
self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否")
|
||||
texts = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "text" and "工单号:" in (x.get("content") or "")]
|
||||
files = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "file"]
|
||||
self.assertTrue(texts and files)
|
||||
self.assertLess(texts[-1], files[-1])
|
||||
self.assertEqual(len(files), 2)
|
||||
|
||||
def test_sales_quote_rejected(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="q0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="空运费 100USD", chat="wr_air_1", mid="q1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "not_airline")
|
||||
|
||||
def test_airline_quote_asks_tms_choice(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="t0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="t1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
injected_quote={"total": "USD 900", "source_label": "航线报价"},
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
self.assertTrue(any("在 TMS 基础上改" in t or "在TMS基础上改" in t for t in self._texts()))
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 800")
|
||||
self.assertNotEqual(ticket.quote.get("source_label"), "航线报价")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="在TMS基础上改", chat="wr_air_1", mid="t2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="quote_keep_tms",
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 900")
|
||||
self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线在TMS上改")
|
||||
self.assertFalse(ticket.collab_facts.get("__pending_air_quote"))
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("工单号:", joined)
|
||||
self.assertIn("询价字段:", joined)
|
||||
self.assertIn("人工报价:", joined)
|
||||
self.assertIn("报价单已发送,请确认成交状态", joined)
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
|
||||
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="nq0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="nq1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
injected_quote={"total": "USD 900", "source_label": "航线报价"},
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 900")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("工单号:", joined)
|
||||
self.assertIn("询价字段:", joined)
|
||||
self.assertIn("TMS 暂无匹配报价", joined)
|
||||
self.assertIn("人工报价:", joined)
|
||||
self.assertIn("报价单已发送,请确认成交状态", joined)
|
||||
self.assertNotIn("航线报价已记下", joined)
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
|
||||
def test_quote_same_sentence_saves_battery(self) -> None:
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="qs0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="含电 空运费900USD", chat="wr_air_1", mid="qs1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
injected_quote={"total": "USD 900", "source_label": "航线报价"},
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("是否含电"), "是")
|
||||
self.assertIn("是否含电:是", "\n".join(self._texts()))
|
||||
|
||||
def test_confirm_tms_saves_sheet_and_deal(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ct0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="确认报价", chat="wr_air_1", mid="ct1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="quote_confirm_tms",
|
||||
)
|
||||
self.assertEqual(phase, "quote_confirmed")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线确认TMS")
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
|
||||
|
||||
def test_stranger_cannot_fill_fields(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sf0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="other1", text="含电 不含磁", chat="wr_air_1", mid="sf1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="collab_fields",
|
||||
)
|
||||
self.assertEqual(phase, "fields_denied")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertFalse(ticket.collab_facts.get("是否含电"))
|
||||
|
||||
def test_sales_deal(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="c0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="c1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="deal_won",
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交")
|
||||
|
||||
def test_lock_requires_work_order(self) -> None:
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="锁舱", chat="wr_air_1", mid="l0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "cabin_need_work_order")
|
||||
self.assertTrue(any("带上工单号" in t for t in self._texts()))
|
||||
|
||||
def test_lock_without_tms_quote(self) -> None:
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "100",
|
||||
"source": "manual",
|
||||
"source_label": "产品报价",
|
||||
},
|
||||
status="已报价",
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_lock_success_and_release_after_deal_blocked(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="deal_won",
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="l4"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_release",
|
||||
)
|
||||
self.assertEqual(phase, "release_deal_blocked")
|
||||
self.assertTrue(any("已成交" in t for t in self._texts()))
|
||||
|
||||
def test_lost_can_release(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="r0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text="未成交", chat="wr_air_1", mid="r1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="deal_lost",
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_release",
|
||||
)
|
||||
self.assertEqual(phase, "release_ok")
|
||||
|
||||
def test_sales_cannot_lock(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "not_airline")
|
||||
|
||||
def test_dual_role_can_lock(self) -> None:
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "WuJiLin",
|
||||
"wecomId": "sales1",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
|
||||
def test_multi_option_asks(self) -> None:
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "USD 800",
|
||||
"source": "TMS",
|
||||
"quoteId": "QT-AIR-2",
|
||||
"airOptions": [
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"},
|
||||
{"optionNo": "AIR-OPT-02", "airlineCode": "MU"},
|
||||
],
|
||||
}
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lock_option")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_pick_option",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-02")
|
||||
|
||||
def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage:
|
||||
return InboundMessage(
|
||||
sender_id=sender,
|
||||
message_id=mid,
|
||||
content=text,
|
||||
msg_type="file",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": filename, "media_id": f"mid-{mid}"},
|
||||
raw={"source": "aibot"},
|
||||
)
|
||||
|
||||
def test_file_needs_work_order(self) -> None:
|
||||
phase = handle_air_group(
|
||||
self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="af0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "need_work_order")
|
||||
|
||||
def test_sales_excel_archived_not_quote(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._file(sender="sales1", filename="对账单.xlsx", chat="wr_air_1", mid="af2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "file_archived")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx")
|
||||
self.assertNotEqual(ticket.quote.get("source_label"), "航线附件报价")
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
|
||||
def test_airline_excel_quotes_and_keeps_tms_id(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "airline_file_quote")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "航线报价.pdf")
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线附件报价")
|
||||
self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
|
||||
|
||||
def test_image_rejected(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af5"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
InboundMessage(
|
||||
sender_id="air1",
|
||||
message_id="af6",
|
||||
content="",
|
||||
msg_type="image",
|
||||
chat_id="wr_air_1",
|
||||
chat_type="group",
|
||||
media={"filename": "现场.jpg", "media_id": "mid-img"},
|
||||
raw={"source": "aibot"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "file_type_rejected")
|
||||
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
|
||||
|
||||
def test_archive_file_without_at_quotes(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ar0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
msg = self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="ar1")
|
||||
msg = InboundMessage(
|
||||
sender_id=msg.sender_id,
|
||||
message_id=msg.message_id,
|
||||
content=msg.content,
|
||||
msg_type="file",
|
||||
chat_id=msg.chat_id,
|
||||
chat_type="group",
|
||||
media=dict(msg.media),
|
||||
raw={"source": "wshoto_archive"},
|
||||
)
|
||||
import agent.handlers.air_group_collab as air_mod
|
||||
orig = air_mod.get_air_text_flow
|
||||
|
||||
def _fake():
|
||||
return self.flow
|
||||
|
||||
air_mod.get_air_text_flow = _fake # type: ignore
|
||||
try:
|
||||
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
|
||||
finally:
|
||||
air_mod.get_air_text_flow = orig # type: ignore
|
||||
self.assertEqual(name, "air_group_collab")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线附件报价")
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
self.assertTrue(any("成交" in t or "未成交" in t or "协商" in t for t in self._texts()))
|
||||
|
||||
def test_archive_text_ignored_until_at(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="ar2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
msg = self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="ar3")
|
||||
msg = InboundMessage(
|
||||
sender_id=msg.sender_id,
|
||||
message_id=msg.message_id,
|
||||
content=msg.content,
|
||||
chat_id=msg.chat_id,
|
||||
chat_type="group",
|
||||
raw={"source": "wshoto_archive"},
|
||||
)
|
||||
import agent.handlers.air_group_collab as air_mod
|
||||
orig = air_mod.get_air_text_flow
|
||||
|
||||
def _fake():
|
||||
return self.flow
|
||||
|
||||
air_mod.get_air_text_flow = _fake # type: ignore
|
||||
try:
|
||||
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
|
||||
finally:
|
||||
air_mod.get_air_text_flow = orig # type: ignore
|
||||
self.assertEqual(name, "air_archive_text_ignore")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已报价")
|
||||
|
||||
def test_archive_at_work_order_activates(self) -> None:
|
||||
wo = self._create_air()
|
||||
msg = InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="ar-at-1",
|
||||
content=f"@询价小助手 {wo}",
|
||||
chat_id="wr_air_new",
|
||||
chat_type="group",
|
||||
raw={"source": "wshoto_archive"},
|
||||
)
|
||||
import agent.handlers.air_group_collab as air_mod
|
||||
import agent.policy.air_text_flow as flow_mod
|
||||
|
||||
orig = air_mod.get_air_text_flow
|
||||
orig2 = flow_mod.get_air_text_flow
|
||||
|
||||
def _fake():
|
||||
return self.flow
|
||||
|
||||
air_mod.get_air_text_flow = _fake # type: ignore
|
||||
flow_mod.get_air_text_flow = _fake # type: ignore
|
||||
try:
|
||||
name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other"))
|
||||
finally:
|
||||
air_mod.get_air_text_flow = orig # type: ignore
|
||||
flow_mod.get_air_text_flow = orig2 # type: ignore
|
||||
self.assertEqual(name, "air_group_collab")
|
||||
self.assertTrue(any(wo in t for t in self._texts()))
|
||||
|
||||
def test_file_with_work_order_activates(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._file(
|
||||
sender="air1",
|
||||
filename="航线报价.xlsx",
|
||||
chat="wr_air_1",
|
||||
mid="af7",
|
||||
text=wo,
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "airline_file_quote")
|
||||
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo)
|
||||
|
||||
def test_archive_same_sentence_different_msgid_sends_one_sheet(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_dup2", mid="bot-mid-012"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated")
|
||||
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
|
||||
phase = handle_air_group(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="wshoto:archive-mid-999",
|
||||
content=f"@询价小助手 {wo}",
|
||||
chat_id="wr_air_dup2",
|
||||
chat_type="group",
|
||||
raw={"source": "wshoto_archive", "msgid": "archive-mid-999"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "duplicate_inbound")
|
||||
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
|
||||
|
||||
def test_aibot_and_archive_same_msgid_sends_one_sheet(self) -> None:
|
||||
wo = self._create_air()
|
||||
import agent.handlers.air_group_collab as air_mod
|
||||
import agent.policy.air_text_flow as flow_mod
|
||||
|
||||
orig = air_mod.get_air_text_flow
|
||||
orig2 = flow_mod.get_air_text_flow
|
||||
|
||||
def _fake():
|
||||
return self.flow
|
||||
|
||||
air_mod.get_air_text_flow = _fake # type: ignore
|
||||
flow_mod.get_air_text_flow = _fake # type: ignore
|
||||
try:
|
||||
aibot = self._msg(sender="sales1", text=wo, chat="wr_air_dup", mid="same-mid-011")
|
||||
name = dispatch_inbound(aibot, RouteDecision(intent="ordinary_text_other"))
|
||||
self.assertEqual(name, "air_group_collab")
|
||||
files_after_bot = sum(1 for x in self.groups.sent if x.get("kind") == "file")
|
||||
self.assertEqual(files_after_bot, 1)
|
||||
archive = InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="wshoto:same-mid-011",
|
||||
content=f"@询价小助手 {wo}",
|
||||
chat_id="wr_air_dup",
|
||||
chat_type="group",
|
||||
raw={"source": "wshoto_archive", "msgid": "same-mid-011"},
|
||||
)
|
||||
name = dispatch_inbound(archive, RouteDecision(intent="ordinary_text_other"))
|
||||
self.assertEqual(name, "air_group_collab")
|
||||
self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1)
|
||||
finally:
|
||||
air_mod.get_air_text_flow = orig # type: ignore
|
||||
flow_mod.get_air_text_flow = orig2 # type: ignore
|
||||
|
||||
def test_sea_ticket_rejected(self) -> None:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="SEA",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "配件", "货量": "1x40HQ", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", "报价日期": "2026-09-16"},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sea1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "not_air")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -83,6 +83,10 @@ class FakeGroupClient:
|
||||
self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card})
|
||||
return {"ok": True}
|
||||
|
||||
def send_text(self, *, touser: str, content: str) -> dict:
|
||||
self.sent.append({"touser": touser, "content": content, "kind": "app_text"})
|
||||
return {"ok": True}
|
||||
|
||||
def dismiss_group(self, *, chat_id: str, userids: list) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
|
||||
return {"ok": True}
|
||||
@@ -591,6 +595,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
self.assertNotIn("未查到合适报价", group_text)
|
||||
self.assertIn("\n\n协同补充字段:\n", group_text)
|
||||
self.assertIn("\n\nTMS报价:\n", group_text)
|
||||
self.assertNotIn("确认报价", group_text)
|
||||
|
||||
|
||||
class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
@@ -694,11 +699,115 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
self.assertNotEqual(str(ticket.quote.get("total") or ""), "1200")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("在TMS基础上改" in t and "不用TMS报价" in t for t in texts))
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g2b",
|
||||
content="按TMS改一下就行",
|
||||
chat_id=ticket.collab_chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
names = [str(x.get("name") or "") for x in (ticket.quote.get("fee_rows") or ticket.quote.get("fee_lines") or [])]
|
||||
self.assertTrue(any("空运费" in n or "海运费" in n or "附加" in n for n in names) or "1280" in str(ticket.quote.get("total") or ""))
|
||||
deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")]
|
||||
self.assertTrue(deal_texts)
|
||||
|
||||
def test_keep_tms_reads_json_string_fee_lines(self) -> None:
|
||||
"""TMS fee_lines 常是 JSON 字符串,在TMS基础上改不能当成没明细。"""
|
||||
from agent.policy.sea_group_ops import merge_quote_fees
|
||||
|
||||
base = {
|
||||
"source_label": "TMS 标准报价",
|
||||
"total": "2287.65",
|
||||
"currency": "CNY",
|
||||
"fee_lines": (
|
||||
'[{"name":"海运费","amount":"100","currency":"USD","unit_price":"100"},'
|
||||
'{"name":"操作费","amount":"680","currency":"CNY","unit_price":"680"},'
|
||||
'{"name":"文件费","amount":"450","currency":"CNY","unit_price":"450"}]'
|
||||
),
|
||||
}
|
||||
overlay = {
|
||||
"source_label": "产品报价",
|
||||
"total": "1200",
|
||||
"currency": "CNY",
|
||||
"fee_rows": [
|
||||
{"name": "操作费", "amount": "700", "currency": "CNY"},
|
||||
{"name": "文件发", "amount": "500", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
merged = merge_quote_fees(base, overlay)
|
||||
by_name = {str(x.get("name") or ""): x for x in (merged.get("fee_rows") or [])}
|
||||
self.assertEqual(str(by_name["海运费"].get("amount") or ""), "100")
|
||||
self.assertEqual(str(by_name["操作费"].get("amount") or ""), "700")
|
||||
self.assertEqual(str(by_name["操作费"].get("unit_price") or ""), "700")
|
||||
self.assertEqual(str(by_name["文件费"].get("amount") or ""), "500")
|
||||
self.assertEqual(str(by_name["文件费"].get("unit_price") or ""), "500")
|
||||
self.assertEqual(str(by_name["海运费"].get("unit_price") or ""), "100")
|
||||
self.assertNotIn("文件发", by_name)
|
||||
self.assertEqual(str(merged.get("total") or ""), "2357.65")
|
||||
self.assertNotEqual(str(merged.get("total") or ""), "1200")
|
||||
|
||||
def test_usd_prefix_fee_lines_are_both_kept(self) -> None:
|
||||
"""海运费:USD 100.00 / 燃油附加费:USD 15.00 两行都要抽出,不能只剩海运费。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote("海运费:USD 100.00\n燃油附加费:USD 15.00")
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("海运费", rows)
|
||||
self.assertIn("燃油附加费", rows)
|
||||
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
|
||||
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote("码头操作费100CNY\n报关费改为300CNY")
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("码头操作费", rows)
|
||||
self.assertIn("报关费", rows)
|
||||
self.assertEqual(str(rows["码头操作费"].get("amount") or ""), "100")
|
||||
self.assertEqual(str(rows["报关费"].get("amount") or ""), "300")
|
||||
self.assertNotEqual(
|
||||
[str(x.get("name") or "") for x in (quote.get("fee_rows") or [])],
|
||||
["海运费"],
|
||||
)
|
||||
|
||||
def test_fee_change_typo_and_colon(self) -> None:
|
||||
"""操作费改为700 + 文件发改为:500,两行都要抽出,文件发要对上文件费。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.policy.sea_group_ops import _fee_name_same, merge_quote_fees
|
||||
|
||||
quote = extract_product_quote("操作费改为700CNY\n文件发改为:500CNY")
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertEqual(str(rows["操作费"].get("amount") or ""), "700")
|
||||
self.assertEqual(str(rows["文件发"].get("amount") or ""), "500")
|
||||
self.assertTrue(_fee_name_same("文件发", "文件费"))
|
||||
self.assertFalse(_fee_name_same("操作费", "码头操作费"))
|
||||
merged = merge_quote_fees(
|
||||
{
|
||||
"source_label": "TMS标准报价",
|
||||
"fee_rows": [
|
||||
{"name": "操作费", "amount": "680", "currency": "CNY"},
|
||||
{"name": "文件费", "amount": "450", "currency": "CNY"},
|
||||
],
|
||||
},
|
||||
quote,
|
||||
)
|
||||
by_name = {str(x.get("name") or ""): x for x in (merged.get("fee_rows") or [])}
|
||||
self.assertEqual(str(by_name["操作费"].get("amount") or ""), "700")
|
||||
self.assertEqual(str(by_name["文件费"].get("amount") or ""), "500")
|
||||
self.assertNotIn("文件发", by_name)
|
||||
|
||||
|
||||
class SeaContinueTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -912,6 +1021,53 @@ class SeaGroupCopyTests(unittest.TestCase):
|
||||
self.assertNotIn("HS编码", text)
|
||||
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertNotIn("未查到合适报价", text)
|
||||
self.assertNotIn("确认报价", text)
|
||||
|
||||
def test_group_brief_has_confirm_hint_when_tms_priced(self) -> None:
|
||||
text = copy.sea_group_brief(
|
||||
work_order_no="WO202609160004",
|
||||
facts={"起运港": "DONGGUAN", "目的港": "海防", "品名": "机器设备"},
|
||||
quote={"total": "2287.65", "currency": "CNY", "valid_until": "2026-09-30"},
|
||||
collab_facts={},
|
||||
sales_name="吴季林",
|
||||
product_names=["林绵勋"],
|
||||
has_price=True,
|
||||
)
|
||||
self.assertIn("报价有效期:2026-09-30", text)
|
||||
self.assertIn("有TMS报价时可回复“确认报价”沿用该价", text)
|
||||
self.assertIn("也可直接回复协同报价或上传报价附件", text)
|
||||
self.assertNotIn("人工报价:", text)
|
||||
|
||||
def test_group_brief_manual_quote_stays_off_tms(self) -> None:
|
||||
text = copy.sea_group_brief(
|
||||
work_order_no="WO202609160006",
|
||||
facts={"起运港": "上海港", "目的港": "海防", "品名": "机器设备"},
|
||||
quote={
|
||||
"source_label": "产品报价",
|
||||
"total": "115",
|
||||
"currency": "USD",
|
||||
"fee_rows": [
|
||||
{"name": "海运费", "amount": "100.00", "currency": "USD"},
|
||||
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
|
||||
],
|
||||
},
|
||||
collab_facts={},
|
||||
sales_name="吴季林",
|
||||
product_names=["林绵勋", "罗小华", "吴季林"],
|
||||
has_price=True,
|
||||
tms_quote={},
|
||||
product_quote={
|
||||
"source_label": "产品报价",
|
||||
"fee_rows": [
|
||||
{"name": "海运费", "amount": "100.00", "currency": "USD"},
|
||||
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertIn("人工报价:\n海运费:USD 100.00\n燃油附加费:USD 15.00", text)
|
||||
self.assertNotIn("确认报价", text)
|
||||
self.assertNotIn("总销售价合计:USD 100.00", text)
|
||||
|
||||
def test_scheme_card_uses_sea_fields(self) -> None:
|
||||
facts = {
|
||||
@@ -950,6 +1106,11 @@ class SeaGroupCopyTests(unittest.TestCase):
|
||||
self.assertIn("1x40HQ", rows["箱型"])
|
||||
self.assertNotIn("包装", rows)
|
||||
|
||||
def test_lost_ack_asks_reason_only(self) -> None:
|
||||
text = copy.deal_ack("未成交", "WO202609160004")
|
||||
self.assertEqual(text, "已记录为「未成交」。请补充未成交原因。")
|
||||
self.assertNotIn("将关闭智能体跟进", text)
|
||||
|
||||
def test_invited_names_carry_role(self) -> None:
|
||||
text = copy.sea_group_created(
|
||||
work_order_no="WO202609150019",
|
||||
@@ -1080,6 +1241,11 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
|
||||
self.assertNotIn("附件已保存后台", texts)
|
||||
ask = [t for t in texts if "在TMS基础上改" in t]
|
||||
self.assertFalse(ask)
|
||||
self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent))
|
||||
|
||||
def test_terminal_file_speaks(self) -> None:
|
||||
@@ -1236,6 +1402,135 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
self.assertEqual(sheet["B2"].value, "8479")
|
||||
self.assertIn("油:否", str(sheet["B3"].value or ""))
|
||||
|
||||
def test_group_deal_text_asks_to_type(self) -> None:
|
||||
"""群里成交跟进是文字版:@销售、请确认、请输入,不要报价合计和请选择。"""
|
||||
text = copy.group_deal_followup(sales_name="吴季林", work_order_no="WO202609160001")
|
||||
self.assertEqual(
|
||||
text,
|
||||
(
|
||||
"@吴季林\n"
|
||||
"询价工单 WO202609160001\n"
|
||||
"报价单已发送,请确认成交状态\n"
|
||||
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
|
||||
),
|
||||
)
|
||||
wo, chat = self._open_group()
|
||||
handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="xls-deal-copy",
|
||||
content="",
|
||||
msg_type="file",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
media={"filename": "产品报价.pdf", "media_id": "mid-deal-copy"},
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
follow = [t for t in texts if "请确认成交状态" in t and "请输入成交" in t]
|
||||
self.assertTrue(follow)
|
||||
self.assertTrue(any(t.startswith("@") and f"询价工单 {wo}" in t for t in follow))
|
||||
self.assertFalse(any("请选择成交" in t or "请跟进成交状态" in t or "总销售价合计" in t for t in follow))
|
||||
|
||||
def test_group_text_deal_does_not_go_to_private_card(self) -> None:
|
||||
"""群里打「成交」不能当私聊点卡丢掉,必须走群成交。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
|
||||
wo, chat = self._open_group()
|
||||
with patch("agent.handlers.group_collab.get_sea_text_flow", return_value=self.flow):
|
||||
result = dispatch_inbound(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="deal-dispatch-1",
|
||||
content="成交",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
RouteDecision(intent="ordinary_text_other"),
|
||||
)
|
||||
self.assertEqual(result, "group_collab")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已成交")
|
||||
|
||||
def test_sales_can_close_deal_after_skinny_snapshot(self) -> None:
|
||||
"""按群找回的工单只有字段、没有报价时,未成交也要能入账。"""
|
||||
wo, chat = self._open_group()
|
||||
real_find = self.ledger.find_by_collab_chat
|
||||
|
||||
def skinny(*, chat_id: str):
|
||||
ticket = real_find(chat_id=chat_id)
|
||||
if ticket is None:
|
||||
return None
|
||||
return {
|
||||
"found": True,
|
||||
"work_order_no": ticket.work_order_no,
|
||||
"sales_wecom_id": ticket.sales_wecom_id,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collab_member_ids": list(ticket.collab_member_ids),
|
||||
"facts": dict(ticket.facts),
|
||||
}
|
||||
|
||||
self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign]
|
||||
self.flow._by_ticket.clear()
|
||||
self.flow._by_sender.clear()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="lost-skinny-1",
|
||||
content="未成交",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
|
||||
|
||||
def test_sales_can_close_deal_by_text(self) -> None:
|
||||
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="deal-text-1",
|
||||
content="成交",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已成交")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("已记录为「已成交」" in t for t in texts))
|
||||
|
||||
def test_product_cannot_close_deal_by_text(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="deal-text-2",
|
||||
content="这个成了",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "not_sales")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertNotEqual(ticket.status, "已成交")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("只有负责该工单的销售" in t for t in texts))
|
||||
|
||||
def test_stale_deal_card_speaks(self) -> None:
|
||||
from agent.policy.sea_group_ops import explain_deal_click
|
||||
|
||||
@@ -1254,6 +1549,301 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("已过期" in t for t in texts))
|
||||
|
||||
def test_sales_who_is_also_product_can_quote(self) -> None:
|
||||
"""拉群销售若后台也是产品·海运,群里按行改价必须收下,不能装没看见。"""
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "吴吉林",
|
||||
"wecomId": "sales1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
},
|
||||
{
|
||||
"name": "赵产品",
|
||||
"wecomId": "prod4",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
},
|
||||
]
|
||||
)
|
||||
wo, chat = self._open_group()
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertIn("sales1", ticket.product_ids)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="dual-role-quote",
|
||||
content="码头操作费100CNY\n报关费改为300CNY",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
|
||||
def test_existing_ticket_sales_later_product_role_can_quote(self) -> None:
|
||||
"""已建群后销售才挂上产品岗:仍按后台岗位认,不能只看建群时的产品名单。"""
|
||||
wo, chat = self._open_group()
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertNotIn("sales1", ticket.product_ids)
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "吴吉林",
|
||||
"wecomId": "sales1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="later-product-quote",
|
||||
content="码头操作费100CNY\n报关费改为300CNY",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
|
||||
def test_product_fee_line_quote_asks_then_keeps_tms(self) -> None:
|
||||
"""有 TMS 价时先问;选「在基础上改」才出单,没点名的 TMS 费用保留。"""
|
||||
wo, chat = self._open_group()
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
ticket.quote = {
|
||||
"total": "2287.65",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS 标准报价",
|
||||
"fee_lines": [
|
||||
{"name": "海运费", "amount": "100.00", "currency": "USD"},
|
||||
{"name": "操作费", "amount": "680.00", "currency": "CNY"},
|
||||
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="fee-line-1",
|
||||
content="码头操作费100CNY\n报关费改为300CNY",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertTrue(any("在TMS基础上改" in t and "不用TMS报价" in t for t in texts))
|
||||
self.assertFalse(any("没有识别到报价" in t for t in texts))
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
before = {str(x.get("name") or ""): x for x in (ticket.quote.get("fee_lines") or [])}
|
||||
self.assertEqual(str(before["报关费"].get("amount") or ""), "250.00")
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="fee-line-2",
|
||||
content="在TMS基础上改",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
names = {
|
||||
str(x.get("name") or ""): x
|
||||
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
|
||||
}
|
||||
self.assertEqual(str(names["报关费"].get("amount") or ""), "300")
|
||||
self.assertEqual(str(names["操作费"].get("amount") or ""), "680.00")
|
||||
self.assertEqual(str(names["码头操作费"].get("amount") or ""), "100")
|
||||
self.assertEqual(str(names["海运费"].get("amount") or ""), "100.00")
|
||||
|
||||
def test_product_text_on_miss_is_manual_not_tms(self) -> None:
|
||||
"""TMS 无价时产品文字报价:摘要 TMS 仍是暂无,人工报价两行齐全。"""
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
self.flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
sess = self.flow.session_of("sales1")
|
||||
chat = sess.collab_chat_id
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="miss-manual-1",
|
||||
content="海运费:USD 100.00\n燃油附加费:USD 15.00",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
briefs = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
text = "\n".join(briefs)
|
||||
self.assertIn("TMS 暂无匹配报价", text)
|
||||
self.assertIn("人工报价:", text)
|
||||
self.assertIn("海运费:USD 100.00", text)
|
||||
self.assertIn("燃油附加费:USD 15.00", text)
|
||||
self.assertNotIn("确认报价", text)
|
||||
|
||||
def test_confirm_tms_saves_sheet_and_notifies_app(self) -> None:
|
||||
"""有 TMS 价时产品回确认报价:模板进后台,群里告诉大家已保存。"""
|
||||
from agent.routing.group_intent import INTENT_CONFIRM_TMS, classify_group_text
|
||||
|
||||
self.assertEqual(classify_group_text("确认报价", allow_network=False), INTENT_CONFIRM_TMS)
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="confirm-tms-1",
|
||||
content="确认报价",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "quote_confirmed")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(ticket.attachments)
|
||||
self.assertIn("quote", str(ticket.attachments[0].get("file_name") or "").lower())
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
|
||||
self.assertTrue(any("成交" in t for t in texts))
|
||||
|
||||
def test_first_option_text_keeps_tms(self) -> None:
|
||||
"""问句列了 1/2 时,回「第一个」要按在 TMS 基础上改,不能装没听见。"""
|
||||
from agent.routing.group_intent import INTENT_KEEP_TMS, classify_group_text
|
||||
|
||||
self.assertEqual(classify_group_text("第一个", allow_network=False), INTENT_KEEP_TMS)
|
||||
wo, chat = self._open_group()
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
ticket.quote = {
|
||||
"total": "2287.65",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS 标准报价",
|
||||
"fee_lines": [
|
||||
{"name": "操作费", "amount": "680", "currency": "CNY", "unit_price": "680"},
|
||||
{"name": "文件费", "amount": "450", "currency": "CNY", "unit_price": "450"},
|
||||
],
|
||||
}
|
||||
handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="first-opt-1",
|
||||
content="操作费改为700CNY / 文件费改为500CNY",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="first-opt-2",
|
||||
content="第一个",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
names = {
|
||||
str(x.get("name") or ""): x
|
||||
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
|
||||
}
|
||||
self.assertEqual(str(names["操作费"].get("amount") or ""), "700")
|
||||
self.assertEqual(str(names["文件费"].get("amount") or ""), "500")
|
||||
|
||||
def test_other_product_can_drop_tms(self) -> None:
|
||||
"""任一产品都能选;说不用 TMS 就只留刚报的价。"""
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "赵产品",
|
||||
"wecomId": "prod4",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
},
|
||||
{
|
||||
"name": "钱产品",
|
||||
"wecomId": "prod5",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
},
|
||||
]
|
||||
)
|
||||
wo, chat = self._open_group()
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
ticket.quote = {
|
||||
"source_label": "TMS 标准报价",
|
||||
"fee_lines": [
|
||||
{"name": "海运费", "amount": "100.00", "currency": "USD"},
|
||||
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="drop-1",
|
||||
content="报关费改为300CNY",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod5",
|
||||
message_id="drop-2",
|
||||
content="不要TMS的价,按我刚才报的",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "product_quote_sent")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
names = {
|
||||
str(x.get("name") or ""): x
|
||||
for x in (ticket.quote.get("fee_lines") or ticket.quote.get("fee_rows") or [])
|
||||
}
|
||||
self.assertEqual(list(names), ["报关费"])
|
||||
self.assertEqual(str(names["报关费"].get("amount") or ""), "300")
|
||||
|
||||
def test_parse_group_file_fills_media(self) -> None:
|
||||
from agent.channel.wecom.parse import parse_plain_xml
|
||||
|
||||
|
||||
@@ -86,6 +86,26 @@ class WshotoParseTests(unittest.TestCase):
|
||||
self.assertEqual(msg.message_id, "wshoto:m1")
|
||||
self.assertEqual(msg.create_time, 1_700_000_000)
|
||||
|
||||
def test_official_text_content_beats_short_preview(self) -> None:
|
||||
"""管家 content 常是摘要或空,正文在企微官方 text.content。必须取更长的那份。"""
|
||||
msg = archive_record_to_inbound(
|
||||
{
|
||||
"msgid": "m1b",
|
||||
"seq": 11,
|
||||
"from": "LuoXiaoHua",
|
||||
"room_id": "wr_room_1",
|
||||
"msgtype": "text",
|
||||
"content": "码头操作费",
|
||||
"msg_content": {
|
||||
"text": {"content": "码头操作费100CNY\n报关费改为300CNY"}
|
||||
},
|
||||
},
|
||||
seat_userids=["AI001"],
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
assert msg is not None
|
||||
self.assertEqual(msg.content, "码头操作费100CNY\n报关费改为300CNY")
|
||||
|
||||
def test_skip_archive_seat_and_display_name(self) -> None:
|
||||
self.assertIsNone(
|
||||
archive_record_to_inbound(
|
||||
|
||||
Reference in New Issue
Block a user