Files
inquiry_robot/inquiry-agent/tests/test_sea_text_inquiry.py
T

2561 lines
103 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
海运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。
覆盖:海运补问、询价确认卡、有价/无价卡、跳过协同、拉群、群里静默补协同字段。
空运话术不得出现在海运路径。
"""
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)
os.environ.setdefault("YTD_ENV", "test")
os.environ.setdefault("REDIS_BACKEND", "memory")
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.channel.wecom.models import InboundMessage
from agent.handlers.group_collab import handle_group_collab
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
COMPLETE_SEA = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
def drive_sea(flow, **kwargs):
"""字段齐时先出核对卡;要看到查价结果的旧用例再自动回一句确定。"""
phase = flow.on_text(**kwargs)
if phase == "wait_confirm":
phase = flow.on_text(sender_id=kwargs["sender_id"], text="确定", reply=kwargs["reply"])
return phase
class SeaCollabContractTests(unittest.TestCase):
def test_collab_no_longer_asks_trade_terms(self):
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
self.assertEqual(
SEA_COLLAB_FIELDS,
("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"),
)
class FakeGroupClient:
"""单测假企微建群:不打外网。"""
def __init__(self) -> None:
self.created: list[dict] = []
self.sent: list[dict] = []
self.fail = False
self.next_chat_id = "wr_sea_1"
def create_group(self, *, name: str, userids: list[str]) -> dict:
if self.fail:
return {"ok": False, "error": "wecom_create_fail", "chat_id": ""}
self.created.append({"name": name, "userids": list(userids)})
return {"ok": True, "chat_id": self.next_chat_id, "error": ""}
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict:
self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True})
return {"ok": True}
def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict:
self.sent.append(
{
"chat_id": chat_id,
"content": content,
"kind": "text",
"mention_userids": list(mention_userids or []),
}
)
return {"ok": True}
def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict:
self.sent.append(
{"chat_id": chat_id, "filename": filename, "kind": "file", "file_b64": file_b64}
)
return {"ok": True}
def send_group_card(self, *, chat_id: str, template_card: dict) -> dict:
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}
class SeaTextInquiryTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
def test_complete_fields_hold_confirm_before_tms(self) -> None:
"""字段齐了只出核对卡,不建单、不查价。"""
phase = self.flow.on_text(
sender_id="s-hold",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
card = self.replies[-1]
self.assertIn(copy.SEA_CONFIRM_TAIL, card)
self.assertIn("运输方式(必填):海运", card)
self.assertIn("货物数量(必填):20吨", card)
self.assertIn("贸易条款(必填):FOB", card)
self.assertNotIn("工单号", card)
self.assertNotIn("正在查询TMS报价", card)
sess = self.flow.session_of("s-hold")
self.assertEqual(sess.phase, "wait_confirm")
self.assertFalse((sess.work_order_no or "").strip())
self.assertEqual(self.ledger._tickets, {})
def test_confirm_phrases_then_query_tms(self) -> None:
"""确定、确认、查价、可以查了 都建单并查价。"""
for word in ("确定", "确认", "查价", "可以查了"):
with self.subTest(word=word):
self.replies.clear()
sender = f"s-go-{word}"
held = self.flow.on_text(
sender_id=sender,
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(held, "wait_confirm")
self.replies.clear()
phase = self.flow.on_text(sender_id=sender, text=word, reply=self._reply)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("正在查询TMS报价", joined)
self.assertIn("工单号:", joined)
sess = self.flow.session_of(sender)
self.assertTrue((sess.work_order_no or "").startswith("WO"))
def test_resend_while_waiting_merges_and_does_not_quote(self) -> None:
"""核对卡出来后改一处,并进原字段再出卡,仍不建单。"""
phase = self.flow.on_text(
sender_id="s-fix",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
self.replies.clear()
fixed = dict(COMPLETE_SEA)
fixed["目的港"] = "纽约"
phase = self.flow.on_text(
sender_id="s-fix",
text="目的港改成纽约",
reply=self._reply,
injected_facts=fixed,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_confirm")
self.assertIn("目的港(必填):纽约", self.replies[-1])
self.assertIn(copy.SEA_CONFIRM_TAIL, self.replies[-1])
sess = self.flow.session_of("s-fix")
self.assertEqual(sess.facts.get("目的港"), "纽约")
self.assertEqual(sess.facts.get("起运港"), "上海")
self.assertFalse((sess.work_order_no or "").strip())
self.assertEqual(self.ledger._tickets, {})
def test_sea_pieces_fill_cargo_qty(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"件数": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertTrue(check["ok"])
self.assertEqual(check["facts"]["货量"], "15件")
self.assertNotIn("货量", check["missing"])
def test_container_shaped_cargo_qty_uses_pieces(self) -> None:
"""货量被抽成箱型时,改用件数。20件、15件同一条规则。"""
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "40HQ*1",
"件数": "20件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"贸易条款": "DOOR-DOOR",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertEqual(check["facts"]["货量"], "20件")
self.assertEqual(check["facts"]["箱型箱量"], "40HQ*1")
def test_sea_missing_container_only_asks_container(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "茶叶",
"货量": "20吨",
"整柜或拼柜": "整柜",
"贸易条款": "FOB",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertEqual(check["missing"], ["箱型箱量"])
self.assertIn("箱型箱量", check["missing_display"])
self.assertNotIn("件数", check["missing"])
self.assertNotIn("毛重", check["missing"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("运输方式:海运", text)
self.assertIn("起运港:上海", text)
self.assertIn("货物数量:20吨", text)
self.assertIn("1.箱型箱量(必填):\n(如:1x40HQ)", text)
self.assertNotIn("箱型箱量:(如", text)
self.assertNotIn("件数", text)
self.assertNotIn("重量", text)
self.assertNotIn("报价日期:(如", text)
self.assertIn("报价日期:", text)
def test_sea_requires_trade_terms_and_class(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertIn("贸易条款", check["missing"])
self.assertIn("运输分类", check["missing"])
self.assertIn("贸易条款", check["missing_display"])
self.assertIn("运输分类", check["missing_display"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("贸易条款(必填):\n(如:门到门、港到港)", text)
self.assertNotIn("如:FOB", text)
def test_sea_rejects_door_to_door_class(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "门到门",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertFalse(check["ok"])
self.assertIn("运输分类", check["missing"])
def test_sea_canonicalizes_p2p(self) -> None:
facts = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "到岸",
"运输分类": "p2p",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertTrue(check["ok"])
self.assertEqual(check["facts"]["运输分类"], "Port to Port")
def test_inquiry_card_lists_ten_fields(self) -> None:
lines = copy.inquiry_field_lines(
work_order_no="WO202609200001",
facts={
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
"运输分类": "Port to Port",
"报价日期": "2026-09-20",
},
transport_mode="海运",
)
text = "\n".join(lines)
self.assertIn("工单号:WO202609200001", text)
self.assertIn("贸易条款:FOB", text)
self.assertIn("运输分类:Port to Port", text)
self.assertLess(text.index("箱型箱量"), text.index("贸易条款"))
self.assertLess(text.index("贸易条款"), text.index("运输分类"))
def test_clarify_asks_class_as_two_options(self) -> None:
text = copy.ask_clarify(
facts={
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "服装",
"货量": "1柜",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"贸易条款": "FOB",
},
missing_keys=["运输分类"],
transport_mode="海运",
)
self.assertIn("1.运输分类(必填):\n(如:Port to Port、其他)", text)
self.assertIn("贸易条款:FOB", text)
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
facts = dict(COMPLETE_SEA)
facts.pop("贸易条款")
phase = drive_sea(self.flow,
sender_id="s-miss",
text="海运缺条款",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.assertIn("贸易条款", "\n".join(self.replies))
self.assertFalse((self.flow.session_of("s-miss").work_order_no or "").startswith("WO"))
def test_leftover_door_to_door_not_written_as_trade_terms(self) -> None:
"""旧草稿里的门到门不能写到确认卡贸易条款。测服 WO202609200013。"""
leftover = {
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"贸易条款": "门到门",
}
self.flow._save(
FlowSession(
sender_id="s-door",
thread_id="s-door",
phase="clarify",
business_line="SEA",
facts=leftover,
)
)
phase = drive_sea(self.flow,
sender_id="s-door",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
reply=self._reply,
injected_facts=dict(leftover),
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
joined = "\n".join(self.replies)
self.assertNotIn("贸易条款:门到门", joined)
self.assertIn("贸易条款", joined)
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
def test_reply_door_to_door_fills_trade_terms(self) -> None:
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
first = dict(COMPLETE_SEA)
first.pop("贸易条款")
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="先缺条款",
reply=self._reply,
injected_facts=first,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = drive_sea(self.flow,
sender_id="s-door-reply",
text="门到门",
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("贸易条款:门到门", joined)
self.assertNotIn("参数提取不全", joined)
def test_door_in_sentence_kept_after_choosing_sea(self) -> None:
"""没写海运、句中有门到门,回「2」后贸易条款要带上,不能再追问。"""
from agent.channel.queue import MemoryMessageStore
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import reset_land_text_flow_for_test
from agent.policy.sea_text_flow import get_sea_text_flow
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
store = MemoryMessageStore()
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="s-door-mode", message_id="m-door-1", content=text),
injected_facts=facts,
store=store,
)
self.assertEqual(phase, "need_mode")
phase = handle_text_inquiry(
InboundMessage(sender_id="s-door-mode", message_id="m-door-2", content="2"),
store=store,
)
body = "\n".join(item.content for item in store._outbox.values())
sess = get_sea_text_flow().session_of("s-door-mode")
self.assertEqual(sess.facts.get("贸易条款"), "门到门")
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
self.assertIn("贸易条款(必填):门到门", body)
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("1.贸易条款(必填)", body)
self.assertNotEqual(phase, "need_mode")
def test_port_to_port_phrase_kept_after_choosing_sea(self) -> None:
"""没写海运、句中有港到港,回「2」后贸易条款是港到港,运输分类仍是 Port to Port。"""
from agent.channel.queue import MemoryMessageStore
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import reset_land_text_flow_for_test
from agent.policy.sea_text_flow import get_sea_text_flow
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
store = MemoryMessageStore()
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港"
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="s-port-mode", message_id="m-port-1", content=text),
injected_facts=facts,
store=store,
)
self.assertEqual(phase, "need_mode")
phase = handle_text_inquiry(
InboundMessage(sender_id="s-port-mode", message_id="m-port-2", content="2"),
store=store,
)
body = "\n".join(item.content for item in store._outbox.values())
sess = get_sea_text_flow().session_of("s-port-mode")
self.assertEqual(sess.facts.get("贸易条款"), "港到港")
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
self.assertIn("贸易条款(必填):港到港", body)
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("1.贸易条款(必填)", body)
self.assertNotEqual(phase, "need_mode")
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")
phase = drive_sea(self.flow,
sender_id="s-class",
text="先缺分类",
reply=self._reply,
injected_facts=first,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = drive_sea(self.flow,
sender_id="s-class",
text="Port to Port",
reply=self._reply,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("运输分类:Port to Port", joined)
self.assertIn("贸易条款:FOB", joined)
def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None:
phase = drive_sea(self.flow,
sender_id="s1",
text="海运完整",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("运输方式:海运", joined)
self.assertIn("起运港:上海", joined)
self.assertIn("目的港:洛杉矶", joined)
self.assertIn("货物数量:20吨", joined)
self.assertIn("整柜或拼柜:整柜", joined)
self.assertIn("箱型箱量:1x40HQ", joined)
self.assertIn("贸易条款:FOB", joined)
self.assertIn("运输分类:Port to Port", joined)
self.assertIn("正在查询TMS报价", joined)
self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0])
self.assertNotIn("本轮先开通空运", joined)
sess = self.flow.session_of("s1")
self.assertEqual(sess.business_line, "SEA")
self.assertEqual(sess.status, "已报价")
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
"""上一单已出号,新一轮不能带上旧贸易条款。"""
first = drive_sea(self.flow,
sender_id="s-round",
text="海运第一轮",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(first, "wait_collab")
old_no = self.flow.session_of("s-round").work_order_no
self.assertTrue((old_no or "").startswith("WO"))
self.replies.clear()
incomplete = {
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = drive_sea(self.flow,
sender_id="s-round",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
reply=self._reply,
injected_facts=incomplete,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
joined = "\n".join(self.replies)
self.assertNotIn("贸易条款:FOB", joined)
self.assertNotIn("贸易条款:门到门", joined)
self.assertIn("贸易条款", joined)
sess = self.flow.session_of("s-round")
self.assertNotEqual(sess.facts.get("贸易条款"), "FOB")
self.assertNotEqual(sess.work_order_no, old_no)
def test_handler_routes_sea_not_air_deferred(self) -> None:
reset_sea_text_flow_for_test()
from agent.channel.queue import MemoryMessageStore
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="s2",
message_id="m-sea-1",
content="海运询价",
)
phase = handle_text_inquiry(
msg,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
flow=self.flow,
store=store,
)
self.assertEqual(phase, "wait_confirm")
self.assertNotEqual(phase, "sea_deferred")
class SeaTmsCardTests(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
def test_assemble_sea_query_does_not_need_air_measures(self) -> None:
ready = assemble_sea_query({**COMPLETE_SEA, "贸易条款": "FOB", "运输分类": "Port to Port"})
self.assertTrue(ready["ok"])
self.assertEqual(ready["missing"], [])
self.assertNotIn("件数", ready.get("missing") or [])
def test_assemble_requires_trade_terms_not_class(self) -> None:
src = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"运输分类": "Port to Port",
}
missing = assemble_sea_query(src)
self.assertFalse(missing["ok"])
self.assertIn("贸易条款", missing["missing"])
self.assertNotIn("运输分类", missing["missing"])
src["贸易条款"] = "FOB"
ready = assemble_sea_query(src)
self.assertTrue(ready["ok"])
self.assertEqual(ready["tms_facts"]["贸易条款"], "FOB")
self.assertNotIn("运输分类", ready["tms_facts"])
self.assertEqual(ready["facts"]["运输分类"], "Port to Port")
def test_sea_quote_text_uses_three_buckets(self) -> None:
quote = {
"total": "6952.77",
"currency": "CNY",
"source_label": "TMS 标准报价",
"polCostItems": [
{"name": "操作费", "amount": "1020", "currency": "CNY"},
{"name": "舱单", "amount": "50", "currency": "CNY"},
],
"oceanCostItems": [{"name": "海运费", "amount": "575", "currency": "USD"}],
"podCostItems": [
{"name": "文件费", "amount": "450", "currency": "CNY"},
{"name": "设备费", "amount": "30", "currency": "CNY"},
],
"fee_total_polCostItems": "1070",
"fee_total_oceanCostItems": "4038",
"fee_total_podCostItems": "470",
}
text = copy.sea_quote_detail_text(work_order_no="WO202609200002", quote=quote)
self.assertIn("始发港费用\n操作费:CNY 1020", text)
self.assertIn("始发港费用小计:CNY 1070", text)
self.assertIn("海运费:USD 575", text)
self.assertIn("海运费小计:CNY 4038", text)
self.assertIn("目的港费用小计:CNY 470", text)
self.assertIn("费用合计:CNY 6952.77", text)
self.assertNotIn("时效", text)
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
"""海运多项费用:完整明细必须在文本里,卡片引用条不得半截。"""
long_quote = {
"total": "2287.65",
"currency": "CNY",
"eta": "1天",
"valid_until": "2026-09-30",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "海运费", "amount": "100.00", "currency": "USD"},
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
{"name": "操作费", "amount": "680.00", "currency": "CNY"},
{"name": "文件费", "amount": "450.00", "currency": "CNY"},
{"name": "封条费(SEAI)", "amount": "50.00", "currency": "CNY"},
{"name": "设备费", "amount": "50.00", "currency": "CNY"},
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
],
}
text = copy.sea_quote_detail_text(
work_order_no="WO202609150030", quote=long_quote
)
self.assertEqual(
text,
"\n".join(
[
"TMS标准报价",
"WO202609150030价格明细",
"海运费:USD 100.00",
"燃油附加费:USD 15.00",
"操作费:CNY 680.00",
"文件费:CNY 450.00",
"封条费(SEAI):CNY 50.00",
"设备费:CNY 50.00",
"报关费:CNY 250.00",
"费用合计:CNY 2287.65",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="SEA")
self.assertEqual(
card_text,
"费用合计:CNY 2287.65\n共7项费用,详见上一条明细",
)
short_quote = {
"total": "100",
"currency": "CNY",
"fee_lines": [{"name": "海运费", "amount": "100", "currency": "CNY"}],
}
self.assertEqual(
copy.quote_area_safe_text(short_quote, business_line="SEA"),
"费用合计:CNY 100\n共1项费用,详见上一条明细",
)
payload = copy.sea_tms_hit_wecom_payload(
work_order_no="WO202609150030", quote=long_quote
)
self.assertEqual(
payload["template_card"]["quote_area"]["quote_text"], card_text
)
sub = payload["template_card"]["sub_title_text"]
self.assertIn("时效:1天", sub)
self.assertIn("报价有效期:2026-09-30", sub)
def test_sea_hit_sends_detail_text_before_card(self) -> None:
"""
有价必须先发出价格明细文字,再发卡。
测服 WO202609200020:卡上写「详见上一条明细」,销售却先看到卡。
"""
steps: list[str] = []
def reply(text: str, extra=None) -> str:
kind = "card" if extra else "text"
steps.append(kind)
return f"out-{len(steps)}"
def wait(out_id: str, *, work_order_no: str, what: str) -> None:
steps.append(f"wait:{out_id}")
self.flow._wait_outbound_sent = wait # type: ignore[method-assign]
self.flow._detail_before_card_gap_sec = 0.0
self.flow._emit_sea_hit(
reply,
work_order_no="WO202609200020",
quote={
"total": "14298.53",
"currency": "CNY",
"source_label": "TMS标准报价",
"fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}],
},
)
self.assertEqual(steps[:3], ["text", "wait:out-1", "card"])
self.assertIn("card", steps)
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
phase = drive_sea(self.flow,
sender_id="s1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("价格明细", joined)
self.assertIn("是否拉产品人员进群协同", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertIn(copy.BTN_SKIP_COLLAB_TMS, joined)
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
self.assertEqual(copy.canonical_button(copy.BTN_SKIP_COLLAB_TMS), copy.BTN_SKIP_COLLAB)
self.assertNotIn("请手动拉相关人员", joined)
self.assertNotIn("若需要咨询内部航线人员", joined)
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(cards)
buttons = [b["text"] for b in cards[-1]["template_card"]["button_list"]]
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB_TMS])
def test_sea_miss_one_card_with_pull_button(self) -> None:
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
phase = drive_sea(self.flow,
sender_id="s1",
text="海运无价",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "tms_miss")
sess = self.flow.session_of("s1")
self.assertEqual(sess.status, "询价中")
joined = "\n".join(self.replies)
self.assertIn("暂无匹配报价", joined)
self.assertIn("询价中", joined)
self.assertIn("未查到合适报价", joined)
self.assertIn("是否需要拉内部产品人员进群", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertNotIn("请手动拉相关人员和智能体到原有群", joined)
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(cards)
buttons = [b["text"] for b in cards[-1]["template_card"].get("button_list") or []]
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB])
def test_sea_tech_fail_is_not_miss(self) -> None:
facts = dict(COMPLETE_SEA)
facts["tms_force"] = "tech"
phase = drive_sea(self.flow,
sender_id="s1",
text="海运技术失败",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "system_exception")
self.assertIn("TMS接口异常,IT运维排查中", "\n".join(self.replies))
self.assertNotIn(copy.BTN_PULL_COLLAB, "\n".join(self.replies))
tickets = list(self.flow._ledger._tickets.values())
self.assertTrue(tickets)
self.assertEqual(tickets[-1].system_exception, "TMS异常")
class SeaSkipAndPullTests(unittest.TestCase):
def setUp(self) -> None:
os.environ["WECOM_ARCHIVE_SEAT_USER_IDS"] = ""
from agent.config import get_settings
get_settings.cache_clear()
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
return ""
def _quote_first(self, sender: str = "s1") -> None:
drive_sea(self.flow,
sender_id=sender,
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
def test_skip_collab_reuses_air_scheme(self) -> None:
self._quote_first()
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-sea-1"},
)
self.assertEqual(phase, "wait_adopt")
ticket = self.ledger.get_ticket(work_order_no=self.flow.session_of("s1").work_order_no)
self.assertEqual(ticket.event_wait_kind, "adopt")
self.assertTrue(ticket.event_wait_started_at)
self.assertIn("已跳过内部协同", "\n".join(self.replies))
scheme = "\n".join(x for x in self.replies if "报价方案" in x)
self.assertIn("运输方式:海运", scheme)
self.assertIn("货物数量:", scheme)
self.assertIn("整柜或拼柜:", scheme)
self.assertIn("箱型箱量:", scheme)
self.assertNotIn("件数:", scheme)
self.assertNotIn("重量:", scheme)
self.assertNotIn("体积:", scheme)
self.assertNotIn("包装类型:", scheme)
cards = [
x.get("template_card") or {}
for x in self.extras
if (x.get("template_card") or {}).get("main_title", {}).get("title", "").endswith("报价方案")
]
self.assertTrue(cards)
rows = {r["keyname"]: r["value"] for r in cards[0].get("horizontal_content_list") or []}
self.assertIn("海运", rows.get("航线", ""))
self.assertIn("箱型", rows)
self.assertNotIn("包装", rows)
again = self.flow.on_button(
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
)
self.assertEqual(again, "card_discard")
def test_pull_group_success_greys_buttons(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self._quote_first()
wo = self.flow.session_of("s1").work_order_no
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-pull-1"},
)
self.assertEqual(phase, "collab_group")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.event_wait_kind, "product_quote")
self.assertTrue(ticket.event_wait_started_at)
joined = "\n".join(self.replies)
self.assertIn("协同群已创建", joined)
self.assertIn(wo, joined)
self.assertIn("销售", joined)
self.assertIn("产品张产品", joined)
self.assertEqual(self.groups.created[0]["name"], wo)
self.assertIn("prod1", self.groups.created[0]["userids"])
self.assertIn("s1", self.groups.created[0]["userids"])
self.assertNotIn("AI001", self.groups.created[0]["userids"])
self.assertTrue(self.groups.sent)
group_text = self.groups.sent[0]["content"]
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
self.assertIn(wo, group_text)
self.assertIn("起运港", group_text)
self.assertIn("贸易条款", group_text)
collab_block = group_text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
self.assertNotIn("贸易条款", collab_block)
self.assertIn("货好时间", collab_block)
self.assertIn("商品海关编码", group_text)
self.assertNotIn("HS编码", group_text)
self.assertIn("TMS", group_text)
sess = self.flow.session_of("s1")
self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed)
self.assertNotIn(copy.BTN_SKIP_COLLAB, sess.allowed)
again = self.flow.on_button(
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
)
self.assertEqual(again, "card_discard")
def test_pull_prefills_spoken_no_battery(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
facts = dict(COMPLETE_SEA)
facts["是否含电"] = "否"
drive_sea(self.flow,
sender_id="s1",
text="海运不含电",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
private = "\n".join(self.replies)
self.assertNotIn("是否含电", private)
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
group_text = "\n".join(
x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"
)
self.assertIn("是否含电:否", group_text)
def test_pull_sends_sheet_when_client_not_injected(self) -> None:
"""测服正式路径不注入 group_client,拉群仍要发出填好的模板。"""
from unittest.mock import patch
live = FakeGroupClient()
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
drive_sea(flow,
sender_id="s9",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
phase = flow.on_button(
sender_id="s9",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
self.assertTrue(any(x.get("kind") == "text" for x in live.sent))
self.assertTrue(any(x.get("kind") == "file" for x in live.sent))
def test_pull_group_includes_archive_bot(self) -> None:
self.ledger.upsert_staff(
{
"name": "询价机器人",
"wecomId": "AI001",
"roleCode": "sales",
"status": "active",
}
)
self.ledger.set_sea_staff(
[
{
"name": "张产品",
"wecomId": "prod1",
"roleCode": "sea",
"routes": "海运全线",
"status": "active",
}
]
)
self._quote_first()
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
userids = self.groups.created[0]["userids"]
self.assertIn("AI001", userids)
self.assertIn("s1", userids)
self.assertIn("prod1", userids)
joined = "\n".join(self.replies)
self.assertIn("产品张产品", joined)
self.assertNotIn("产品询价机器人", joined)
self.assertNotIn("销售询价机器人", joined)
def test_pull_no_staff_keeps_button(self) -> None:
self.ledger.set_sea_staff([])
self._quote_first()
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
self.assertIn("没有可拉的产品人员", "\n".join(self.replies))
self.assertEqual(self.groups.created, [])
self.assertIn(copy.BTN_PULL_COLLAB, self.flow.session_of("s1").allowed)
def test_pull_already_has_group_no_second(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "李产品",
"wecomId": "prod2",
"roleCode": "sea",
"routes": "上海",
"status": "active",
}
]
)
self._quote_first()
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
n = len(self.groups.created)
self.flow.session_of("s1").allowed = (copy.BTN_PULL_COLLAB,)
phase = self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(phase, "collab_group")
self.assertEqual(len(self.groups.created), n)
self.assertIn("群已在", "\n".join(self.replies))
def test_miss_group_message_writes_no_quote(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "王产品",
"wecomId": "prod3",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
facts = dict(COMPLETE_SEA)
facts["目的港"] = "无价港"
drive_sea(self.flow,
sender_id="s1",
text="海运无价",
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="s1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertTrue(self.groups.sent)
group_text = self.groups.sent[0]["content"]
self.assertIn("TMS 暂无匹配报价", group_text)
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):
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
def test_group_text_ignores_trade_terms_saves_ready_date(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
wo = self.flow.session_of("sales1").work_order_no
ticket = self.ledger.get_ticket(work_order_no=wo)
chat_id = ticket.collab_chat_id
n = len(self.replies)
ignored = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g1",
content="贸易条款:CIF",
chat_id=chat_id,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(ignored, "collab_not_recognized")
self.assertFalse(ticket.collab_facts.get("贸易条款"))
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g2",
content="货好时间:下周",
chat_id=chat_id,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "collab_field_saved")
self.assertEqual(len(self.replies), n)
self.assertEqual(ticket.collab_facts.get("货好时间"), "下周")
def test_group_price_chat_is_ignored(self) -> None:
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
wo = self.flow.session_of("sales1").work_order_no
ticket = self.ledger.get_ticket(work_order_no=wo)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="g2",
content="报价 1200 美元可以做",
chat_id=ticket.collab_chat_id,
chat_type="group",
),
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")
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_land_weight_band_unit_price_and_rates(self) -> None:
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
)
self.assertEqual(str(quote.get("total") or ""), "990")
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
tiers = list(quote.get("weightPrices") or [])
self.assertEqual(len(tiers), 1)
self.assertEqual(int(tiers[0].get("min") or 0), 2)
self.assertEqual(int(tiers[0].get("max") or 0), 500)
self.assertEqual(str(tiers[0].get("price") or ""), "990")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("2-500KG", rows)
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"空运段,空运费 单价CNY99.00,数量100 ,金额CNY199.00@询价小助手"
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("空运费", rows)
self.assertNotIn("海运费", rows)
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(quote.get("total") or ""), "199.0")
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:
from agent.redis_coord.runtime import shutdown_redis_runtime
shutdown_redis_runtime()
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.flow = SeaTextInquiryFlow(self.ledger)
self.replies: list[str] = []
self.extras: list[dict] = []
def _reply(self, text: str, extra=None) -> str:
self.replies.append(text)
self.extras.append(dict(extra) if extra else {})
return ""
def test_continue_after_skip_replays_sea_scheme_not_air(self) -> None:
"""已跳过协同后再续办:必须海运方案卡,不能套空运件数包装。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
drive_sea(self.flow,
sender_id="c1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="c1",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-cont-1"},
)
wo = self.flow.session_of("c1").work_order_no
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c1",
message_id="m-cont-sea",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=self.flow
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_adopt")
cards = [
(item.payload or {}).get("template_card") or {}
for item in store._outbox.values()
]
titles = [
(c.get("main_title") or {}).get("title") or "" for c in cards if c
]
self.assertTrue(any(t == f"{wo}报价方案" for t in titles))
rows = {}
for card in cards:
if (card.get("main_title") or {}).get("title") == f"{wo}报价方案":
rows = {
r["keyname"]: r["value"]
for r in card.get("horizontal_content_list") or []
}
self.assertIn("海运", rows.get("航线", ""))
self.assertIn("箱型", rows)
self.assertNotIn("包装", rows)
def test_continue_from_ledger_uses_sea_collab_card(self) -> None:
"""进程无书签:主账海运已报价应回收海运协同卡,不是空运卡。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
created = self.ledger.create_ticket(
sender_id="c2", business_line="SEA", facts=COMPLETE_SEA
)
wo = created["work_order_no"]
self.ledger.upsert_quote(
work_order_no=wo,
quote={
"total": "USD 1,200",
"eta": "18天",
"valid_until": "2026-09-20",
"source_label": "TMS 标准报价",
},
to_status="已报价",
)
empty = SeaTextInquiryFlow(self.ledger)
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c2",
message_id="m-cont-ledger",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_collab")
sess = empty.session_by_ticket("c2", wo)
self.assertIsNotNone(sess)
self.assertEqual(sess.business_line, "SEA")
joined = "\n".join(item.content for item in store._outbox.values())
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertIn("是否拉产品人员进群协同", joined)
self.assertNotIn("包装方式", joined)
def test_continue_after_quote_file_returns_deal(self) -> None:
"""报价单已发出后再续办:必须成交跟进,不能退回生成报价单。"""
from unittest.mock import patch
from agent.channel.queue import MemoryMessageStore
from agent.handlers.continue_thread import handle_continue_thread
from agent.policy.air_text_flow import AirTextInquiryFlow
drive_sea(self.flow,
sender_id="c3",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="c3",
action=copy.BTN_SKIP_COLLAB,
reply=self._reply,
card_meta={"response_code": "rc-deal-1"},
)
wo = self.flow.session_of("c3").work_order_no
self.flow.on_button(
sender_id="c3",
action=copy.BTN_ADOPT_EXCEL,
reply=self._reply,
card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"},
)
self.assertEqual(self.flow.session_of("c3").phase, "wait_deal")
file_extras = [x for x in self.extras if x.get("msgtype") == "file"]
self.assertTrue(file_extras)
follow = file_extras[-1].get("followup_card") or {}
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
separate_deal = [
x
for x in self.extras
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
]
self.assertEqual(separate_deal, [])
empty = SeaTextInquiryFlow(self.ledger)
store = MemoryMessageStore()
msg = InboundMessage(
sender_id="c3",
message_id="m-cont-deal",
content=f"继续工单{wo}",
)
air_trap = AirTextInquiryFlow(MemoryLedger())
with patch(
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
), patch(
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
):
phase = handle_continue_thread(msg, thread_id=wo, store=store)
self.assertEqual(phase, "wait_deal")
joined = "\n".join(item.content for item in store._outbox.values())
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
class SeaGroupCopyTests(unittest.TestCase):
def test_group_brief_uses_customs_code_and_section_breaks(self) -> None:
text = copy.sea_group_brief(
work_order_no="WO202609150023",
facts={
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"报价日期": "2026-09-15",
},
quote=None,
collab_facts={},
sales_name="吴季林",
product_names=["罗小华"],
has_price=False,
)
self.assertIn("@吴季林 @罗小华", text)
self.assertIn("询价字段:\n运输方式:海运", text)
self.assertIn("\n\n协同补充字段:\n", text)
self.assertIn("商品海关编码:-", text)
self.assertIn("货好时间:-", text)
collab_block = text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
self.assertNotIn("贸易条款", collab_block)
self.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("航线人员可通过", 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("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("请航线人员回复", 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("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", 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 = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"报价日期": "2026-09-15",
}
quote = {"total": "USD 1200", "eta": "18天", "valid_until": "2026-09-20"}
text = copy.scheme_card(
work_order_no="WO202609150021",
status="已报价",
facts=facts,
quote=quote,
transport_mode="海运",
)
self.assertIn("运输方式:海运", text)
self.assertIn("货物数量:15件", text)
self.assertIn("箱型箱量:1x40HQ", text)
self.assertNotIn("件数:", text)
self.assertNotIn("包装类型:", text)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
work_order_no="WO202609150021",
facts=facts,
quote=quote,
transport_mode="海运",
)
}
self.assertIn("海运 上海→洛杉矶", rows["航线"])
self.assertIn("15件", rows["货物"])
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",
sales_name="吴季林",
product_names=["罗小华"],
)
self.assertEqual(
text,
"协同群已创建,群名与工单号一致:WO202609150019。"
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
)
def test_group_only_excel_pdf_copy(self) -> None:
self.assertEqual(
copy.group_only_excel_pdf(),
"群里只收 Excel 和 PDF,其它文件不会存入工单",
)
class SeaGroupOpsTests(unittest.TestCase):
"""群协同:附件类型、产品报价、解散后再拉、终态提醒。"""
def setUp(self) -> None:
self.ledger = MemoryLedger()
self.ledger.set_quote_templates(
[
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
}
]
)
self.ledger.set_sea_staff(
[
{
"name": "赵产品",
"wecomId": "prod4",
"roleCode": "sea",
"routes": "海运·全部线路",
"status": "active",
}
]
)
self.ledger.upsert_staff({"name": "吴吉林", "wecomId": "sales1"})
self.groups = FakeGroupClient()
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
self.replies: list[str] = []
def _reply(self, text: str, extra=None) -> None:
self.replies.append(text)
def _open_group(self) -> tuple[str, str]:
drive_sea(self.flow,
sender_id="sales1",
text="海运有价",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
sess = self.flow.session_of("sales1")
return sess.work_order_no, sess.collab_chat_id
def test_group_rejects_non_excel_pdf(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="img1",
content="",
msg_type="image",
chat_id=chat,
chat_type="group",
media={"filename": "现场.jpg", "media_id": "mid-1"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "file_type_rejected")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn("群里只收 Excel 和 PDF,其它文件不会存入工单", texts)
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertFalse(ticket.attachments)
def test_sales_excel_is_archived_not_quote(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="xls1",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "对账单.xlsx", "media_id": "mid-2"},
),
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"), "产品附件报价")
def test_product_excel_quotes_and_deal(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls2",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "产品报价.pdf", "media_id": "mid-3"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "product_file_quote")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.event_wait_kind, "deal")
self.assertTrue(ticket.event_wait_started_at)
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:
wo, chat = self._open_group()
self.ledger.transition(work_order_no=wo, to_status="已成交")
sess = self.flow.session_of("sales1")
sess.status = "已成交"
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="xls3",
content="",
msg_type="file",
chat_id=chat,
chat_type="group",
media={"filename": "晚到.xlsx", "media_id": "mid-4"},
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "ticket_locked_file")
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))
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
def test_handoff_group_stays_silent(self) -> None:
from agent.routing.group_intent import INTENT_FIELDS
wo, chat = self._open_group()
self.ledger.transition(work_order_no=wo, to_status="转人工")
sess = self.flow.session_of("sales1")
sess.status = "转人工"
sess.handoff_committed = True
self.flow._save(sess)
before = len(self.groups.sent)
phase = handle_group_collab(
InboundMessage(
sender_id="prod4",
message_id="h1",
content="客户名称张三",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_FIELDS,
)
self.assertEqual(phase, "handoff_ignore")
after = [x.get("content") or "" for x in self.groups.sent[before:] if x.get("kind") == "text"]
self.assertFalse(any("不能修改工单信息" in t for t in after))
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertNotIn("客户名称", ticket.collab_facts or {})
def test_dissolve_then_cannot_recreate(self) -> None:
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="dis1",
content="这个群解散吧",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="dissolve",
)
self.assertEqual(phase, "group_dissolved")
self.assertTrue(any(x.get("kind") == "dismiss" for x in self.groups.sent))
self.assertTrue(self.ledger.get_ticket(work_order_no=wo).collab_ended)
sess = self.flow.session_of("sales1")
sess.allowed = (copy.BTN_PULL_COLLAB,)
again = self.flow.on_button(
sender_id="sales1",
action=copy.BTN_PULL_COLLAB,
reply=self._reply,
)
self.assertEqual(again, "collab_ended")
self.assertIn("不能再拉群", "\n".join(self.replies))
def test_group_oral_keeps_inquiry_facts_when_snapshot_skinny(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),
"collab_facts": dict(ticket.collab_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="oral-keep-facts-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
texts = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
self.assertIn("上海", texts)
self.assertIn("洛杉矶", texts)
self.assertIn("随时可提", texts)
self.assertNotRegex(texts, r"起运港:\s*-")
self.assertIn("@吴吉林 @赵产品", texts)
self.assertNotRegex(texts, r"^@销售(\s|$)")
briefs = [
x
for x in self.groups.sent
if x.get("kind") == "text" and "询价字段" in (x.get("content") or "")
]
self.assertTrue(briefs)
self.assertIn("sales1", briefs[-1].get("mention_userids") or [])
self.assertIn("prod4", briefs[-1].get("mention_userids") or [])
self.assertNotIn("AI001", briefs[-1].get("mention_userids") or [])
def test_group_oral_oil_power_ready_is_saved(self) -> None:
wo, chat = self._open_group()
n = len([x for x in self.groups.sent if x.get("kind") == "text"])
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="oral-collab-1",
content="不含电、不含油、随时可提",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="collab_fields",
)
self.assertEqual(phase, "collab_field_saved")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.collab_facts.get("是否含电"), "否")
self.assertEqual(ticket.collab_facts.get("是否含油"), "否")
self.assertEqual(ticket.collab_facts.get("货好时间"), "随时可提")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertGreater(len(texts), n)
def test_collab_facts_are_projected_for_quote_sheet(self) -> None:
from agent.policy.quote_overlay import overlay_labeled_facts
from agent.policy.sea_group_ops import _facts_for_fill
from openpyxl import Workbook, load_workbook
filled = _facts_for_fill(
{"起运港": "DONGGUAN", "目的港": "海防"},
{"货好时间": "随时可提", "是否含油": "否", "是否含电": "否", "HS编码": "8479"},
)
self.assertEqual(filled["cargo_ready_date"], "随时可提")
self.assertEqual(filled["hs_code"], "8479")
self.assertIn("油:否", filled["oil_elec_mag"])
self.assertIn("电:否", filled["oil_elec_mag"])
import tempfile
from pathlib import Path
wb = Workbook()
ws = wb.active
ws["A1"] = "*货好时间/Cargo Ready Date:"
ws["A2"] = "*商品海关编码/HS Code:"
ws["A3"] = "*是否含油,电,磁/OIL,ELEC,MAG:"
path = str(Path(tempfile.mkdtemp(prefix="collab-sheet-")) / "src.xlsx")
wb.save(path)
out = overlay_labeled_facts(filepath=path, facts=filled)
painted = load_workbook(out["filepath"])
sheet = painted.active
self.assertEqual(sheet["B1"].value, "随时可提")
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, "wait_lost_reason")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
def test_sales_next_text_is_lost_reason(self) -> None:
wo, chat = self._open_group()
handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-1",
content="未成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-2",
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).lost_reason, "客人比价了")
def test_sales_lost_and_reason_same_message(self) -> None:
"""群里一句同时说未成交和原因,不再追问。"""
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-inline-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, "未成交")
self.assertEqual(ticket.lost_reason, "客户不想要了")
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
self._open_group()
sess = self.flow.session_of("sales1")
sess.deal_version = 2
sess.collab_chat_id = sess.collab_chat_id or "wr_sea_1"
reason = explain_deal_click(
flow=self.flow,
sender_id="sales1",
action=copy.BTN_DEAL,
card_meta={"event_key": f"deal:{sess.work_order_no}:v1"},
sess=sess,
)
self.assertEqual(reason, "stale_deal")
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["目的港"] = "无价港"
drive_sea(self.flow,
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)
saved_name = str(ticket.attachments[0].get("file_name") or "")
self.assertEqual(saved_name, f"{wo}海运默认.xlsx")
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
xml = (
"<xml><FromUserName>prod4</FromUserName><CreateTime>1</CreateTime>"
"<MsgType>file</MsgType><MediaId>mid-x</MediaId>"
"<FileName>报价.xlsx</FileName><ChatId>wr_sea_1</ChatId></xml>"
)
msg = parse_plain_xml(xml)
self.assertEqual(msg.chat_type, "group")
self.assertEqual(msg.chat_id, "wr_sea_1")
self.assertEqual(msg.media.get("filename"), "报价.xlsx")
self.assertEqual(msg.media.get("media_id"), "mid-x")
if __name__ == "__main__":
unittest.main()