让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 10:36:55 +08:00
co-authored by Cursor
parent cb3bf5238b
commit a7a8487eb3
22 changed files with 1630 additions and 126 deletions
+349 -30
View File
@@ -238,7 +238,8 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("总销售价合计", joined)
self.assertIn("时效:", joined)
self.assertIn("报价有效期:", joined)
self.assertIn("相同询价内容检索:首次询价", joined)
self.assertNotIn("相同询价内容检索", joined)
self.assertNotIn("引用历史工单号", joined)
self.assertNotIn("工单状态:已报价", joined)
self.assertNotIn("【价格明细】", joined)
@@ -292,9 +293,44 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertIn("Excel", "\n".join(self.replies))
self.assertIn("咨询成交状态", "\n".join(self.replies))
deal_cards = [
x["template_card"]
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.assertTrue(deal_cards)
deal_rows = {r["keyname"]: r["value"] for r in deal_cards[-1]["horizontal_content_list"]}
self.assertIn("总销售价合计", deal_rows)
self.assertNotIn("历史报价", deal_rows)
self.assertNotIn("工单状态", deal_rows)
self.assertNotIn("来源", deal_rows)
phase = self.flow.on_button(sender_id="u1", action=copy.BTN_DEAL, reply=self._reply)
file_idx = next(
i for i, x in enumerate(self.extras) if x.get("msgtype") == "file"
)
deal_idx = next(
i
for i, x in enumerate(self.extras)
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
)
self.assertLess(file_idx, deal_idx)
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_DEAL,
reply=self._reply,
card_meta={"response_code": "rc-deal-1"},
)
self.assertEqual(phase, "done")
deal_updates = [
x
for x in self.extras
if x.get("msgtype") == "update_template_card"
and x.get("response_code") == "rc-deal-1"
]
self.assertTrue(deal_updates)
self.assertEqual(deal_updates[-1].get("button", {}).get("replace_name"), "已成交")
ticket = self.flow.ledger.get_ticket(
work_order_no=self.flow.session_of("u1").work_order_no
)
@@ -367,14 +403,14 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯")
self.assertFalse(called[0]["facts"].get("originCode"))
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
self.assertIn("工单编号WO202609140088", self.replies[-1])
self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies))
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(miss)
self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价")
self.assertEqual(
miss[-1]["template_card"]["quote_area"]["quote_text"],
"请务必在群聊发送工单编号WO202609140088",
miss[-1]["template_card"]["main_title"]["title"],
"询价工单 WO202609140088",
)
def test_unknown_airport_no_card(self) -> None:
@@ -389,7 +425,8 @@ class AirTextInquiryTests(unittest.TestCase):
)
# 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
self.assertIn("工单编号", self.replies[-1])
def test_tms_miss_copy(self) -> None:
facts = dict(COMPLETE_AIR)
@@ -402,21 +439,90 @@ class AirTextInquiryTests(unittest.TestCase):
injected_mode="AIR",
)
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertIn("工单编号", self.replies[-1])
wo = self.flow.session_of("u2").work_order_no
self.assertEqual(self.replies[-1], copy.tms_miss_followup(work_order_no=wo))
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(miss)
card = miss[-1]["template_card"]
self.assertEqual(card["card_type"], "text_notice")
self.assertEqual(card["source"]["desc"], "TMS查价结果")
self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价")
self.assertNotIn("desc", card["main_title"])
self.assertEqual(card["sub_title_text"], copy.tms_miss_hint())
self.assertEqual(
card["quote_area"]["quote_text"],
copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no),
)
self.assertNotIn("horizontal_content_list", card)
self.assertEqual(card["main_title"]["title"], f"询价工单 {wo}")
self.assertEqual(card["main_title"]["desc"], copy.tms_miss_hint())
self.assertEqual(card["sub_title_text"], copy.tms_miss_body())
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(rows["工单状态"], "询价中")
self.assertEqual(rows["查价结果"], "暂无匹配报价")
self.assertNotIn("来源", rows)
self.assertNotIn("button_list", card)
self.assertEqual(self.flow.session_of("u2").allowed, ())
def test_inquiry_must_send_before_tms_query(self) -> None:
"""询价确认出站完成之前不能打 TMS,避免暂无报价卡抢先。"""
import threading
import time
from unittest.mock import patch
from agent.channel.queue import TaskState
store = MemoryMessageStore()
query_saw: list[str] = []
class _Led:
def create_ticket(self, **kwargs):
return {
"ok": True,
"work_order_no": "WO202609150111",
"first_or_same": "first",
}
def query_tms(self, **kwargs):
inq = [
i
for i in store._outbox.values()
if "正在查询TMS报价" in (i.content or "")
]
query_saw.append(inq[0].state.value if inq else "missing")
return {
"ok": True,
"has_price": False,
"classification": "TMS_NO_QUOTE_1002",
}
def reply(text: str, extra=None) -> str:
_ok, oid = store.enqueue_outbound(
touser="u_ord",
content=text,
payload=dict(extra or {}),
)
return oid
stop = threading.Event()
def pump() -> None:
while not stop.is_set():
item = store.claim_outbound()
if item:
store.complete_outbound(item.id)
else:
time.sleep(0.01)
worker = threading.Thread(target=pump, daemon=True)
worker.start()
try:
with patch("agent.channel.queue.get_message_store", return_value=store):
flow = AirTextInquiryFlow(_Led())
phase = flow.on_text(
sender_id="u_ord",
text="空运",
reply=reply,
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
finally:
stop.set()
self.assertEqual(phase, "tms_miss")
self.assertEqual(query_saw, [TaskState.DONE.value])
def test_incomplete_skip_is_not_no_quote(self) -> None:
"""主账未出站不能对销售说暂无报价。"""
@@ -469,7 +575,9 @@ class AirTextInquiryTests(unittest.TestCase):
)
no2 = self.flow.session_of("u4").work_order_no
self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies))
self.assertIn(f"引用历史工单号{no1}", "\n".join(self.replies))
self.assertNotIn("相同询价内容检索", "\n".join(self.replies))
self.assertNotIn("引用历史工单号", "\n".join(self.replies))
self.assertEqual(self.flow.session_of("u4").history_work_order_no, no1)
self.assertTrue(no1)
self.assertTrue(no2)
self.assertNotEqual(no1, no2)
@@ -562,18 +670,31 @@ class AirTextInquiryTests(unittest.TestCase):
card = payload["template_card"]
self.assertEqual(card["card_type"], "text_notice")
self.assertEqual(card["source"]["desc"], "TMS查价结果")
self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价")
self.assertNotIn("desc", card["main_title"])
self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群")
self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008")
self.assertNotIn("title", card["quote_area"])
self.assertNotIn("horizontal_content_list", card)
self.assertEqual(card["main_title"]["title"], "询价工单 WO202606160008")
self.assertEqual(card["main_title"]["desc"], "TMS 标准价库暂无匹配报价")
self.assertEqual(card["sub_title_text"], "需求已解析并创建工单。")
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(rows, {
"工单状态": "询价中",
"查价结果": "暂无匹配报价",
})
self.assertNotIn("来源", rows)
self.assertNotIn("button_list", card)
self.assertNotIn("quote_area", card)
self.assertNotIn("card_action", card)
with_url = copy.tms_miss_wecom_payload(
work_order_no="WO202606160008",
action_url="https://example.test",
)["template_card"]
self.assertEqual(with_url["card_action"]["url"], "https://example.test")
self.assertEqual(
copy.tms_miss(work_order_no="WO202606160008"),
"询价工单WO202606160008已建立,TMS标准价库暂无匹配报价。",
)
self.assertEqual(
copy.tms_miss_followup(work_order_no="WO202606160008"),
"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号WO202606160008",
)
def test_quote_card_payload_has_clickable_button(self) -> None:
payload = copy.tms_hit_wecom_payload(
@@ -623,7 +744,8 @@ class AirTextInquiryTests(unittest.TestCase):
sub = card["sub_title_text"]
self.assertIn("时效:3-5 天", sub)
self.assertIn("报价有效期:2026-09-20", sub)
self.assertIn("相同询价内容检索:首次询价", sub)
self.assertNotIn("相同询价内容检索", sub)
self.assertNotIn("引用历史工单号", sub)
self.assertNotIn("工单状态", sub)
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
@@ -710,6 +832,67 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL)
self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL)
def test_deal_card_matches_followup_layout(self) -> None:
quote = {
"total": "722.5",
"currency": "CNY",
"eta": "2天",
"eta_date": "2026-08-23",
"valid_until": "2026-08-24",
"source_label": "TMS 标准报价",
}
text = copy.deal_card(work_order_no="WO202608210017", quote=quote)
self.assertIn("询价工单 WO202608210017", text)
self.assertIn("报价单已发送,请跟进成交状态", text)
self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text)
self.assertIn("总销售价合计:CNY 722.5", text)
self.assertIn("时效:2天", text)
self.assertNotIn("时效(要求/TMS)", text)
self.assertNotIn("- / -", text)
self.assertIn("报价有效期:2026-08-24", text)
self.assertNotIn("历史报价", text)
self.assertNotIn("工单状态", text)
self.assertNotIn("来源:", text)
self.assertNotIn("总成本价合计", text)
payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote)
card = payload["template_card"]
self.assertEqual(payload["msgtype"], "template_card")
self.assertEqual(card["card_type"], "button_interaction")
self.assertEqual(card["source"]["desc"], "TMS标准报价")
self.assertEqual(card["main_title"]["title"], "询价工单 WO202608210017")
self.assertEqual(card["main_title"]["desc"], "报价单已发送,请跟进成交状态")
self.assertEqual(card["sub_title_text"], copy.DEAL_HINT)
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(
rows,
{
"总销售价合计": "CNY 722.5",
"时效": "2天",
"报价有效期": "2026-08-24",
},
)
self.assertNotIn("历史报价", rows)
self.assertNotIn("工单状态", rows)
self.assertNotIn("来源", rows)
self.assertEqual(
[x["text"] for x in card["button_list"]],
[copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE],
)
self.assertEqual(card["button_list"][0]["key"], "deal:WO202608210017")
self.assertTrue(str(card["task_id"]).startswith("deal-WO202608210017-"))
self.assertEqual(
copy.work_order_from_card_meta({"event_key": "lost:WO202608210017"}),
"WO202608210017",
)
self.assertEqual(copy.canonical_button("deal:WO202608210017"), copy.BTN_DEAL)
self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE)
empty_rows = {
x["keyname"]: x["value"]
for x in copy.deal_horizontal_rows(quote={"total": "-", "valid_until": "-"})
}
self.assertEqual(empty_rows["时效"], "-")
self.assertNotEqual(empty_rows["时效"], "- / -")
def test_same_demand_cites_latest_even_if_closed(self) -> None:
ledger = MemoryLedger()
flow = AirTextInquiryFlow(ledger)
@@ -745,7 +928,9 @@ class AirTextInquiryTests(unittest.TestCase):
wo2 = flow.session_of("hist-b").work_order_no
self.assertTrue(wo2)
self.assertNotEqual(wo2, wo1)
self.assertIn(f"引用历史工单号{wo1}", "\n".join(replies_b))
self.assertEqual(flow.session_of("hist-b").history_work_order_no, wo1)
self.assertNotIn("相同询价内容检索", "\n".join(replies_b))
self.assertNotIn("引用历史工单号", "\n".join(replies_b))
replies_c: list[str] = []
def _keep_c(text: str, extra=None) -> None:
@@ -761,8 +946,10 @@ class AirTextInquiryTests(unittest.TestCase):
)
wo3 = flow.session_of("hist-c").work_order_no
self.assertNotEqual(wo3, wo2)
self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c))
self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c))
self.assertEqual(flow.session_of("hist-c").history_work_order_no, wo2)
self.assertNotEqual(flow.session_of("hist-c").history_work_order_no, wo1)
self.assertNotIn("相同询价内容检索", "\n".join(replies_c))
self.assertNotIn("引用历史工单号", "\n".join(replies_c))
def test_card_event_key_matches_skip_button(self) -> None:
from agent.channel.wecom.parse import parse_plain_xml
@@ -990,6 +1177,138 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(ledger.last_render.get("matchType"), "default")
self.assertEqual(ledger.last_render.get("templateName"), "默认空运")
def test_pdf_queue_does_not_emit_deal_before_file(self) -> None:
"""PDF 还在 LibreOffice 队列时,不能先出成交跟进卡。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
if __name__ == "__main__":
unittest.main()
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
flow.on_text(
sender_id="u-pdf-q",
text="空运",
reply=lambda t, extra=None: replies.append(t),
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
flow.on_button(
sender_id="u-pdf-q",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: replies.append(t),
)
flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
phase = flow.on_button(
sender_id="u-pdf-q",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_file")
self.assertNotIn("咨询成交状态", "\n".join(replies))
self.assertEqual(flow.session_of("u-pdf-q").phase, "wait_file")
again: list[str] = []
replay = flow._replay_wait(flow.session_of("u-pdf-q"), lambda t, extra=None: again.append(t), source="test")
self.assertEqual(replay, "wait_file")
self.assertIn("请稍候", "\n".join(again))
self.assertNotIn("咨询成交状态", "\n".join(again))
def test_worker_file_ready_unblocks_http_wait_file(self) -> None:
"""Worker 出完文件后 HTTP 进程必须能切到成交跟进,不能一直请稍候。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
http = AirTextInquiryFlow(ledger)
replies: list[str] = []
http.on_text(
sender_id="u-pdf-x",
text="空运",
reply=lambda t, extra=None: replies.append(t),
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
http.on_button(
sender_id="u-pdf-x",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: replies.append(t),
)
http._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
http.on_button(
sender_id="u-pdf-x",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_file")
wo = http.session_of("u-pdf-x").work_order_no
worker = AirTextInquiryFlow(ledger)
worker.mark_wait_deal("u-pdf-x", wo)
again: list[str] = []
replay = http._replay_wait(
http.session_of("u-pdf-x"),
lambda t, extra=None: again.append(t),
source="test",
)
self.assertEqual(replay, "wait_deal")
self.assertIn("咨询成交状态", "\n".join(again))
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
def test_deal_click_works_while_http_still_wait_file(self) -> None:
"""成交卡已发出时,点成交不能因为书签还停在 wait_file 就没反应。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
flow = AirTextInquiryFlow(ledger)
flow.on_text(
sender_id="u-pdf-d",
text="空运",
reply=lambda t, extra=None: None,
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: None,
)
flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: None,
)
self.assertEqual(flow.session_of("u-pdf-d").phase, "wait_file")
extras: list[dict] = []
phase = flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_DEAL,
reply=lambda t, extra=None: extras.append(dict(extra or {})),
card_meta={"response_code": "rc-stuck-deal"},
)
self.assertEqual(phase, "done")
self.assertEqual(flow.session_of("u-pdf-d").phase, "done")
self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras))
@@ -0,0 +1,55 @@
"""
复制工单号页:token 不带工单号,打开页能看到工单号,过期给 410。
"""
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("LEDGER_BACKEND", "memory")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from agent.channel.h5 import router as h5_router
from agent.channel.h5.copy_page import render_copy_page
from agent.channel.h5.copy_store import build_copy_ticket_url, lookup_copy_token
class CopyTicketH5Tests(unittest.TestCase):
def test_token_roundtrip_and_page(self) -> None:
url = build_copy_ticket_url("WO202609150001")
self.assertIn("/inquiry-form/copy/", url)
token = url.rstrip("/").rsplit("/", 1)[-1]
self.assertEqual(lookup_copy_token(token), "WO202609150001")
self.assertNotIn("WO202609150001", token)
html = render_copy_page("WO202609150001")
self.assertIn("WO202609150001", html)
self.assertIn("setClipboardData", html)
def test_http_copy_page(self) -> None:
url = build_copy_ticket_url("WO202609150002")
token = url.rstrip("/").rsplit("/", 1)[-1]
app = FastAPI()
app.include_router(h5_router)
client = TestClient(app)
hit = client.get(f"/inquiry-form/copy/{token}")
self.assertEqual(hit.status_code, 200)
self.assertIn("WO202609150002", hit.text)
miss = client.get("/inquiry-form/copy/not-a-real-token")
self.assertEqual(miss.status_code, 410)
self.assertIn("过期", miss.text)
if __name__ == "__main__":
unittest.main()