444 lines
19 KiB
Python
444 lines
19 KiB
Python
"""
|
||
陆运群内协同:摘要只列本单协同项、补字段过滤、预填。
|
||
不连企微 / 真实 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.ledger.memory_ledger import MemoryLedger
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||
from agent.routing.group_intent import INTENT_ADJUST, INTENT_FIELDS, INTENT_QUOTE
|
||
|
||
|
||
class CountingLedger(MemoryLedger):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.last_created_no = ""
|
||
|
||
def create_ticket(self, **kwargs):
|
||
out = super().create_ticket(**kwargs)
|
||
self.last_created_no = str(out.get("work_order_no") or "")
|
||
return out
|
||
|
||
|
||
class FakeGroupClient:
|
||
def __init__(self) -> None:
|
||
self.created: list[dict] = []
|
||
self.sent: list[dict] = []
|
||
|
||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||
self.created.append({"name": name, "userids": list(userids)})
|
||
return {"ok": True, "chat_id": "wr_land_collab", "error": ""}
|
||
|
||
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict:
|
||
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"})
|
||
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"})
|
||
return {"ok": True}
|
||
|
||
|
||
class LandGroupCollabTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.ledger = CountingLedger()
|
||
self.ledger.set_quote_templates(
|
||
[
|
||
{
|
||
"templateId": "mem-default-land",
|
||
"name": "陆运默认",
|
||
"bizType": "陆运",
|
||
"keywords": [],
|
||
"isDefault": True,
|
||
}
|
||
]
|
||
)
|
||
self.ledger.set_land_staff(
|
||
[
|
||
{
|
||
"roleCode": "land",
|
||
"status": "active",
|
||
"wecomId": "p_dom",
|
||
"name": "乙",
|
||
"routes": "国内长途/零担,国内城配/拖车/打包,中港/中亚/中欧,东南亚",
|
||
}
|
||
]
|
||
)
|
||
self.groups = FakeGroupClient()
|
||
self.flow = LandTextInquiryFlow(self.ledger, group_client=self.groups)
|
||
self.replies: list[str] = []
|
||
|
||
def _reply(self, text: str, extra=None) -> str:
|
||
self.replies.append(text)
|
||
return ""
|
||
|
||
def _group_texts(self) -> str:
|
||
return "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text")
|
||
|
||
def _open(
|
||
self,
|
||
*,
|
||
option_text: str,
|
||
extra_facts: dict | None = None,
|
||
cargo: dict | None = None,
|
||
) -> tuple[str, str]:
|
||
facts = {
|
||
"起运港": "广州",
|
||
"目的港": "深圳",
|
||
"品名": "衣服",
|
||
"体积": "100CBM",
|
||
"毛重": "50kg",
|
||
}
|
||
facts.update(cargo or {})
|
||
facts.update(extra_facts or {})
|
||
self.flow.on_text(
|
||
sender_id="u1",
|
||
text=option_text,
|
||
reply=self._reply,
|
||
injected_facts=facts,
|
||
injected_mode="LAND",
|
||
)
|
||
self.flow.on_text(sender_id="u1", text="确定", reply=self._reply)
|
||
self.replies.clear()
|
||
phase = self.flow.on_button(
|
||
sender_id="u1",
|
||
action=copy.BTN_PULL_COLLAB,
|
||
reply=self._reply,
|
||
)
|
||
self.assertEqual(phase, "collab_group")
|
||
wo = self.flow.session_of("u1").work_order_no
|
||
return wo, "wr_land_collab"
|
||
|
||
def _msg(self, *, sender: str, text: str, mid: str) -> InboundMessage:
|
||
return InboundMessage(
|
||
sender_id=sender,
|
||
message_id=mid,
|
||
content=text,
|
||
msg_type="text",
|
||
chat_id="wr_land_collab",
|
||
chat_type="group",
|
||
)
|
||
|
||
def test_ltl_brief_only_four_collab_and_prefill_pack(self) -> None:
|
||
self._open(
|
||
option_text="国内运输拼车 国内长途/零担 急件 包装方式纸箱",
|
||
extra_facts={"包装方式": "纸箱"},
|
||
)
|
||
brief = self._group_texts()
|
||
self.assertIn("包装方式:纸箱", brief)
|
||
self.assertIn("客户名称:-", brief)
|
||
self.assertIn("货值:-", brief)
|
||
self.assertIn("是否为危险品:-", brief)
|
||
self.assertNotIn("贸易条款", brief)
|
||
self.assertNotIn("商品海关编码", brief)
|
||
self.assertNotIn("海关编码", brief)
|
||
|
||
def test_domestic_ftl_longhaul_can_pull_and_brief_order(self) -> None:
|
||
"""国内整车配长途:允许拉群;协同顺序是客户名称、货值、包装方式、是否为危险品。"""
|
||
self._open(
|
||
option_text="国内运输整车 国内长途/零担 急件",
|
||
cargo={"车型/数量": "9米/1"},
|
||
)
|
||
self.assertTrue(self.groups.created)
|
||
brief = self._group_texts()
|
||
collab_part = brief.split("协同补充字段")[1]
|
||
self.assertIn("客户名称:-", collab_part)
|
||
self.assertIn("货值:-", collab_part)
|
||
self.assertIn("包装方式:-", collab_part)
|
||
self.assertIn("是否为危险品:-", collab_part)
|
||
self.assertLess(collab_part.find("客户名称"), collab_part.find("货值"))
|
||
self.assertLess(collab_part.find("货值"), collab_part.find("包装方式"))
|
||
self.assertLess(collab_part.find("包装方式"), collab_part.find("是否为危险品"))
|
||
self.assertNotIn("贸易条款", brief)
|
||
self.assertNotIn("海关编码", brief)
|
||
# 车型/数量只在询价字段,不进协同。
|
||
self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0])
|
||
self.assertNotIn("车型/数量", collab_part)
|
||
|
||
def test_cross_ftl_vehicle_stays_inquiry(self) -> None:
|
||
wo, _ = self._open(
|
||
option_text="跨境整车 东南亚 急件",
|
||
cargo={
|
||
"目的港": "河内",
|
||
"车型/数量": "9米/1",
|
||
},
|
||
)
|
||
brief = self._group_texts()
|
||
self.assertIn("车型/数量:9米/1", brief.split("协同补充字段")[0])
|
||
self.assertNotIn("车型/数量", brief.split("协同补充字段")[1])
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="车型/数量 12米/1", mid="v1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
self.assertEqual(phase, "collab_not_recognized")
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.facts.get("车型/数量"), "9米/1")
|
||
self.assertFalse(ticket.collab_facts.get("车型/数量"))
|
||
|
||
def test_hk_ftl_vehicle_is_collab(self) -> None:
|
||
wo, _ = self._open(
|
||
option_text="中港整车 中港/中亚/中欧 急件",
|
||
cargo={"目的港": "香港"},
|
||
)
|
||
brief = self._group_texts()
|
||
collab_part = brief.split("协同补充字段")[1]
|
||
self.assertIn("车型/数量:-", collab_part)
|
||
self.assertNotIn("车型/数量", brief.split("协同补充字段")[0])
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="车型/数量 9米/1", mid="hk1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
self.assertEqual(phase, "collab_field_saved")
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.collab_facts.get("车型/数量"), "9米/1")
|
||
self.assertFalse(ticket.facts.get("车型/数量"))
|
||
self.assertIn("车型/数量:9米/1", self._group_texts())
|
||
|
||
def test_ltl_rejects_trade_terms_only(self) -> None:
|
||
self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="贸易条款 FOB", mid="t1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
self.assertEqual(phase, "collab_not_recognized")
|
||
text = self._group_texts()
|
||
self.assertIn("客户名称", text)
|
||
self.assertNotIn("货好时间", text)
|
||
|
||
def test_ltl_keeps_name_drops_trade_terms(self) -> None:
|
||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="客户名称张三,贸易条款 FOB", mid="t2"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
self.assertEqual(phase, "collab_field_saved")
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.collab_facts.get("客户名称"), "张三")
|
||
self.assertFalse(ticket.collab_facts.get("贸易条款"))
|
||
self.assertIn("客户名称:张三", self._group_texts())
|
||
|
||
def test_dg_and_value_and_bare_pack(self) -> None:
|
||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
handle_group_collab(
|
||
self._msg(sender="u1", text="普货", mid="dg1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.collab_facts.get("是否为危险品"), "否")
|
||
handle_group_collab(
|
||
self._msg(sender="u1", text="危险品", mid="dg2"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.collab_facts.get("是否为危险品"), "是")
|
||
handle_group_collab(
|
||
self._msg(sender="u1", text="货值 10万 包装方式编织袋", mid="v1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.collab_facts.get("货值"), "10万")
|
||
self.assertEqual(ticket.collab_facts.get("包装方式"), "编织袋")
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="编织袋", mid="v2"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_FIELDS,
|
||
)
|
||
self.assertEqual(phase, "collab_not_recognized")
|
||
|
||
def test_product_can_quote_without_collab(self) -> None:
|
||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
phase = handle_group_collab(
|
||
self._msg(sender="p_dom", text="运费 1000", mid="q1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_QUOTE,
|
||
injected_quote={"total": "1000", "source_label": "产品报价"},
|
||
)
|
||
self.assertIn(phase, {"product_quote_sent", "wait_tms_choice"})
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertTrue(ticket.quote)
|
||
|
||
def test_sales_adjust_sends_excel_and_deal(self) -> None:
|
||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="u1", text="空运费加 10%", mid="adj1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_ADJUST,
|
||
)
|
||
self.assertEqual(phase, "group_adjusted")
|
||
texts = self._group_texts()
|
||
self.assertIn("已识别调价指令", texts)
|
||
self.assertIn("调价后价格", texts)
|
||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertEqual(ticket.status, "已报价")
|
||
|
||
def test_product_fee_change_is_quote_not_adjust(self) -> None:
|
||
"""产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。"""
|
||
from agent.routing.group_intent import classify_group_text
|
||
|
||
self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE)
|
||
self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST)
|
||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
handle_group_collab(
|
||
self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_QUOTE,
|
||
injected_quote={"total": "1000", "source_label": "产品报价"},
|
||
)
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
)
|
||
self.assertNotEqual(phase, "adjust_not_sales")
|
||
self.assertNotIn("只有这张单的销售能调价", self._group_texts())
|
||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||
self.assertTrue(ticket.quote)
|
||
|
||
def test_product_cannot_adjust(self) -> None:
|
||
self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||
self.groups.sent.clear()
|
||
phase = handle_group_collab(
|
||
self._msg(sender="p_dom", text="空运费减 5%", mid="adj2"),
|
||
flow=self.flow,
|
||
ledger=self.ledger,
|
||
injected_intent=INTENT_ADJUST,
|
||
)
|
||
self.assertEqual(phase, "adjust_not_sales")
|
||
self.assertIn("只有这张单的销售能调价。产品请继续报价,价格未改。", self._group_texts())
|
||
|
||
def test_air_room_adjust_names_airline(self) -> None:
|
||
self.assertEqual(
|
||
copy.adjust_only_sales(air_room=True),
|
||
"只有这张单的销售能调价。航线请继续报价,价格未改。",
|
||
)
|
||
|
||
def test_land_bare_price_defaults_to_land_fee(self) -> None:
|
||
"""陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。"""
|
||
from agent.llm.extract_text import extract_product_quote
|
||
|
||
land = extract_product_quote("价格为1RMB/KG", business_line="LAND")
|
||
rows = list(land.get("fee_rows") or [])
|
||
self.assertTrue(rows)
|
||
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
|
||
self.assertEqual(str(land.get("total") or ""), "1")
|
||
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
|
||
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
|
||
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
|
||
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
|
||
|
||
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
|
||
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
|
||
from agent.llm.extract_text import extract_product_quote
|
||
from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
|
||
|
||
tms = {
|
||
"currency": "HKD",
|
||
"cost": "HKD 1000",
|
||
"total": "1000",
|
||
"source": "TMS",
|
||
"fee_lines": [
|
||
{
|
||
"name": "运输费",
|
||
"amount": "1000",
|
||
"currency": "HKD",
|
||
}
|
||
],
|
||
"landSegments": [
|
||
{
|
||
"origin": "广东省-深圳市-福田区",
|
||
"destination": "香港特别行政区",
|
||
"currency": "HKD",
|
||
"costPrice": "1000",
|
||
}
|
||
],
|
||
}
|
||
self.assertEqual(_quote_currency_hint(tms), "HKD")
|
||
# 旧行为:无 baseline 会落到 USD
|
||
legacy = extract_product_quote("运输费调整为1100", business_line="LAND")
|
||
self.assertNotEqual(str(legacy.get("currency") or ""), "USD")
|
||
quote = extract_product_quote(
|
||
"运输费调整为1100",
|
||
business_line="LAND",
|
||
baseline_currency=_quote_currency_hint(tms),
|
||
)
|
||
self.assertEqual(str(quote.get("currency") or ""), "HKD")
|
||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("amount") or ""), "1100")
|
||
merged = merge_quote_fees(tms, quote)
|
||
self.assertEqual(str((merged.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||
self.assertEqual(str(merged.get("currency") or ""), "HKD")
|
||
|
||
def test_land_requote_replaces_wrong_sea_fee_name(self) -> None:
|
||
"""旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。"""
|
||
from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees
|
||
|
||
base = {
|
||
"fee_rows": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||
"fee_lines": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||
"total": "1",
|
||
"source_label": "产品报价",
|
||
}
|
||
overlay = {
|
||
"fee_rows": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||
"fee_lines": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||
"total": "2",
|
||
"source_label": "产品报价",
|
||
}
|
||
aligned = _align_wrong_default_freight(base, overlay, business_line="LAND")
|
||
merged = merge_quote_fees(aligned, overlay)
|
||
names = [str(x.get("name") or "") for x in (merged.get("fee_rows") or [])]
|
||
self.assertEqual(names, ["陆运费"])
|
||
self.assertEqual(str((merged.get("fee_rows") or [{}])[0].get("amount") or ""), "2")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|