落地海运协同群:测服靠会话存档听群话,报价单能带上协同字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 18:12:45 +08:00
co-authored by Cursor
parent 235ecd8c83
commit 12219cacb1
39 changed files with 4436 additions and 256 deletions
+448 -11
View File
@@ -62,8 +62,29 @@ class FakeGroupClient:
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) -> dict:
self.sent.append({"chat_id": chat_id, "content": content})
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 dismiss_group(self, *, chat_id: str, userids: list) -> dict:
self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)})
return {"ok": True}
@@ -213,21 +234,40 @@ class SeaTmsCardTests(unittest.TestCase):
],
}
text = copy.sea_quote_detail_text(
work_order_no="WO202609150029", quote=long_quote
work_order_no="WO202609150030", quote=long_quote
)
self.assertIn("海运费:USD 100.00", text)
self.assertIn("报关费:CNY 250.00", text)
self.assertIn("总销售价合计:CNY 2287.65", text)
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)
self.assertIn("总销售价合计:CNY 2287.65", card_text)
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
self.assertLessEqual(len(card_text), 80)
payload = copy.sea_tms_hit_wecom_payload(
work_order_no="WO202609150029", quote=long_quote
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_card_has_price_and_two_buttons(self) -> None:
phase = self.flow.on_text(
@@ -393,9 +433,12 @@ class SeaSkipAndPullTests(unittest.TestCase):
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)
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)
@@ -405,6 +448,40 @@ class SeaSkipAndPullTests(unittest.TestCase):
)
self.assertEqual(again, "card_discard")
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)
flow.on_text(
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(
{
@@ -509,12 +586,27 @@ class SeaSkipAndPullTests(unittest.TestCase):
reply=self._reply,
)
self.assertTrue(self.groups.sent)
self.assertIn("暂无匹配报价", self.groups.sent[0]["content"])
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)
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] = []
@@ -602,8 +694,10 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "group_ignore")
self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote))
self.assertEqual(phase, "product_quote_sent")
self.assertEqual(str(ticket.quote.get("total") or ""), "1200")
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)
class SeaContinueTests(unittest.TestCase):
@@ -761,6 +855,17 @@ class SeaContinueTests(unittest.TestCase):
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(
@@ -777,11 +882,37 @@ class SeaContinueTests(unittest.TestCase):
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.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交")))
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.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertNotIn("未查到合适报价", text)
def test_scheme_card_uses_sea_fields(self) -> None:
facts = {
"起运港": "上海",
@@ -831,6 +962,312 @@ class SeaGroupCopyTests(unittest.TestCase):
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
)
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]:
self.flow.on_text(
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.attachments[0]["file_name"], "产品报价.pdf")
self.assertEqual(ticket.status, "已报价")
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_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_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_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()