机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
激活发群模版不得登记主账附件;正式有价出单必须登记。
|
||||
|
||||
验收:
|
||||
- 激活路径 _render_and_send_sheet 默认 persist_attachment=False;
|
||||
- 有价出单显式传 True;
|
||||
- 确认落库 _persist_quote_sheet 传 True。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent.policy import sea_group_ops
|
||||
|
||||
|
||||
class _Ledger:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def render_quote(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(kwargs))
|
||||
return {
|
||||
"ok": True,
|
||||
"fileName": "WO1空运到港报价单",
|
||||
"fileBase64": "UEsDBAoAAAAAA",
|
||||
"objectKey": "inquiry/work-order-files/WO1.xlsx",
|
||||
"attachmentSaved": bool(kwargs.get("persist_attachment")),
|
||||
"templateId": "t1",
|
||||
"templateName": "空运到港报价单",
|
||||
}
|
||||
|
||||
|
||||
def test_activate_send_does_not_persist_attachment(monkeypatch) -> None:
|
||||
ledger = _Ledger()
|
||||
flow = SimpleNamespace(ledger=ledger)
|
||||
client = MagicMock()
|
||||
client.send_group_file.return_value = {"ok": True}
|
||||
monkeypatch.setattr(sea_group_ops, "group_client_of", lambda _f: client)
|
||||
monkeypatch.setattr(
|
||||
sea_group_ops,
|
||||
"overlay_labeled_facts",
|
||||
lambda **_k: {"filepath": "", "file_b64": "UEsDBAoAAAAAA"},
|
||||
)
|
||||
|
||||
out = sea_group_ops._render_and_send_sheet(
|
||||
flow=flow,
|
||||
chat_id="chat-1",
|
||||
work_order_no="WO202610170054",
|
||||
facts={"weight_kg": "11"},
|
||||
quote={},
|
||||
sales_id="u1",
|
||||
business_line="AIR",
|
||||
)
|
||||
|
||||
assert out.get("ok") is True
|
||||
assert len(ledger.calls) == 1
|
||||
assert ledger.calls[0].get("persist_attachment") is False
|
||||
|
||||
|
||||
def test_priced_send_persists_attachment(monkeypatch) -> None:
|
||||
"""正式有价出单:群发 Excel 同时写后台附件(WO102 类缺附件回归)。"""
|
||||
ledger = _Ledger()
|
||||
flow = SimpleNamespace(ledger=ledger)
|
||||
client = MagicMock()
|
||||
client.send_group_file.return_value = {"ok": True}
|
||||
monkeypatch.setattr(sea_group_ops, "group_client_of", lambda _f: client)
|
||||
monkeypatch.setattr(
|
||||
sea_group_ops,
|
||||
"overlay_labeled_facts",
|
||||
lambda **_k: {"filepath": "", "file_b64": "UEsDBAoAAAAAA"},
|
||||
)
|
||||
|
||||
out = sea_group_ops._render_and_send_sheet(
|
||||
flow=flow,
|
||||
chat_id="chat-1",
|
||||
work_order_no="WO202610170102",
|
||||
facts={"weight_kg": "11"},
|
||||
quote={"total": "9900", "currency": "CNY"},
|
||||
sales_id="u1",
|
||||
business_line="AIR",
|
||||
persist_attachment=True,
|
||||
)
|
||||
|
||||
assert out.get("ok") is True
|
||||
assert len(ledger.calls) == 1
|
||||
assert ledger.calls[0].get("persist_attachment") is True
|
||||
|
||||
|
||||
def test_confirm_persist_quote_sheet_requests_attachment(monkeypatch) -> None:
|
||||
ledger = _Ledger()
|
||||
flow = SimpleNamespace(ledger=ledger)
|
||||
monkeypatch.setattr(sea_group_ops, "_archive_file", lambda *_a, **_k: None)
|
||||
|
||||
ticket = {
|
||||
"work_order_no": "WO202610170054",
|
||||
"business_line": "AIR",
|
||||
"facts": {"weight_kg": "11"},
|
||||
"collab_facts": {},
|
||||
"sales_wecom_id": "u1",
|
||||
"sales_name": "张三",
|
||||
}
|
||||
sess = SimpleNamespace(
|
||||
work_order_no="WO202610170054",
|
||||
facts={"weight_kg": "11"},
|
||||
collab_facts={},
|
||||
sender_id="u1",
|
||||
)
|
||||
sea_group_ops._persist_quote_sheet(
|
||||
flow=flow,
|
||||
ticket=ticket,
|
||||
sess=sess,
|
||||
quote={"total": "100"},
|
||||
sender_id="u1",
|
||||
business_line="AIR",
|
||||
)
|
||||
|
||||
assert len(ledger.calls) == 1
|
||||
assert ledger.calls[0].get("persist_attachment") is True
|
||||
@@ -174,6 +174,43 @@ class AirAddedServicesTests(unittest.TestCase):
|
||||
self.assertTrue(assemble_air_query(bare)["ok"])
|
||||
self.assertNotIn("added_services", assemble_air_query(bare)["facts"])
|
||||
|
||||
def test_empty_trade_terms_does_not_swallow_added_services(self) -> None:
|
||||
"""
|
||||
空运补问复制清单:贸易条款留空时,不能把「10.增值服务」收成贸易条款。
|
||||
|
||||
测服复现:核对卡出现「贸易条款(非必填):10.增值服务」。
|
||||
"""
|
||||
multi = (
|
||||
"1.起运地(必填):南京\n"
|
||||
"2.目的地(必填):吉隆坡\n"
|
||||
"3.品名(必填):普货\n"
|
||||
"4.件数(必填):10\n"
|
||||
"5.重量(KG)(必填):21\n"
|
||||
"6.体积(CBM)(必填):1.1*1.2*1.3\n"
|
||||
"7.包装类型(必填):散货\n"
|
||||
"8.贸易条款(非必填):\n"
|
||||
"9.增值服务(非必填):卡转低消、前置仓\n"
|
||||
"10.报价日期(非必填):"
|
||||
)
|
||||
same_line = (
|
||||
"1.起运地(必填):南京\n"
|
||||
"2.目的地(必填):吉隆坡\n"
|
||||
"3.品名(必填):普货\n"
|
||||
"4.件数(必填):10\n"
|
||||
"5.重量(KG)(必填):2\n"
|
||||
"6.体积(CBM)(必填):1.2*1.3*1.5\n"
|
||||
"7.包装类型(必填):散货\n"
|
||||
"8.贸易条款(非必填):9.增值服务(非必填):卡转低消、前置仓\n"
|
||||
"10.报价日期(非必填):"
|
||||
)
|
||||
for text in (multi, same_line):
|
||||
snap = extract_inquiry_snapshot(text, injected_mode="AIR")
|
||||
facts = snap["facts"]
|
||||
trade = str(facts.get("贸易条款") or "")
|
||||
self.assertFalse(trade, msg=repr(trade))
|
||||
self.assertNotIn("增值服务", trade)
|
||||
self.assertEqual(facts.get("added_services"), "卡转低消、前置仓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -357,6 +357,91 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertNotIn("空运费", names)
|
||||
self.assertEqual(copy.quote_fee_lines(projected)[0][0], "运输费")
|
||||
|
||||
def test_oms_top_fee_items_merged_when_cabin_only_has_surcharges(self) -> None:
|
||||
"""OMS 表价:空运费在整单 feeItems,舱位只有增点/拆板时群摘要必须带上空运段。"""
|
||||
quote = {
|
||||
"total": "3334.0",
|
||||
"currency": "CNY",
|
||||
"remark": "空运报价来源:OMS空运表价;顶层价格为排序后的第一条直达航班方案",
|
||||
"feeItems": [
|
||||
{
|
||||
"feeCode": "AIR_FREIGHT",
|
||||
"feeName": "空运费",
|
||||
"currency": "CNY",
|
||||
"unit": "KG",
|
||||
"unitPrice": 10.0,
|
||||
"quantity": 333.4,
|
||||
"amount": 3334.0,
|
||||
}
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "NKG",
|
||||
"destinationAirportCode": "KUL",
|
||||
"optionNo": "AIR-OPT01",
|
||||
"salePrice": 3334.0,
|
||||
"currency": "CNY",
|
||||
"chargeableWeightKg": 333.4,
|
||||
"departureCharges": [
|
||||
{"chargeName": "增点费", "unit": "点", "currency": "CNY", "unitPrice": 100},
|
||||
{"chargeName": "拆板费", "unit": "板数", "currency": "USD", "unitPrice": 100},
|
||||
],
|
||||
}
|
||||
],
|
||||
"valid_until": "2026-10-15",
|
||||
}
|
||||
facts = {"originCode": "NKG", "destinationCode": "KUL"}
|
||||
projected = project_group_air_quote(quote, facts)
|
||||
names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])]
|
||||
self.assertEqual(names, ["空运费", "增点费", "拆板费"])
|
||||
brief = copy.air_group_brief(
|
||||
work_order_no="WO202610150047",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=quote,
|
||||
)
|
||||
self.assertIn("空运费", brief)
|
||||
self.assertIn("增点费", brief)
|
||||
self.assertIn("拆板费", brief)
|
||||
|
||||
def test_oms_injects_air_freight_when_fee_items_missing(self) -> None:
|
||||
"""落库后若顶层 feeItems 丢失,仅增点/拆板时仍用舱位总价补空运费。"""
|
||||
quote = {
|
||||
"total": "4303.0",
|
||||
"currency": "CNY",
|
||||
"remark": "空运报价来源:OMS空运表价;顶层价格为排序后的第一条直达航班方案",
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "NKG",
|
||||
"destinationAirportCode": "KUL",
|
||||
"optionNo": "AIR-OPT01",
|
||||
"salePrice": 4303.0,
|
||||
"currency": "CNY",
|
||||
"chargeableWeightKg": 333.4,
|
||||
"departureCharges": [
|
||||
{"chargeName": "增点费", "unit": "点", "currency": "CNY", "unitPrice": 100},
|
||||
{"chargeName": "拆板费", "unit": "板数", "currency": "USD", "unitPrice": 100},
|
||||
],
|
||||
}
|
||||
],
|
||||
"valid_until": "2026-10-31",
|
||||
}
|
||||
facts = {"originCode": "NKG", "destinationCode": "KUL"}
|
||||
projected = project_group_air_quote(quote, facts)
|
||||
names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])]
|
||||
self.assertEqual(names[0], "空运费")
|
||||
self.assertIn("增点费", names)
|
||||
private = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202610170071",
|
||||
quote=quote,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
self.assertIn("空运费", private)
|
||||
self.assertIn("价格方案如下", private)
|
||||
|
||||
def test_air_detail_text_hides_eta_keeps_valid_until(self) -> None:
|
||||
"""空运没有时效;价格明细只留报价有效期。"""
|
||||
text = copy.sea_quote_detail_text(
|
||||
|
||||
@@ -151,6 +151,38 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
land = validate_required_fields(facts={"运输类型": "国内整车"}, business_line="LAND")
|
||||
self.assertTrue(land["facts"].get("报价日期"))
|
||||
|
||||
def test_paste_hint_must_not_become_quote_date(self) -> None:
|
||||
"""
|
||||
WO202610170068:补问提示误写入报价日期后出「线路」。
|
||||
|
||||
提示文案必须当空日期,建单摘要不能原样展示,normalize 后不能留在 facts。
|
||||
"""
|
||||
from agent.llm.extract_text import _harvest_labels, clip_inquiry_value
|
||||
from agent.schema.field_validate import is_blank_quote_date, normalize_facts
|
||||
|
||||
hint = copy.CLARIFY_PASTE_HINT
|
||||
self.assertTrue(is_blank_quote_date(hint))
|
||||
self.assertEqual(clip_inquiry_value(hint), "")
|
||||
cleaned = normalize_facts({"报价日期": hint, "目的港": "吉隆坡"})
|
||||
self.assertNotIn("报价日期", cleaned)
|
||||
squeezed = f"报价日期(非必填):{hint}\n件数(必填):10"
|
||||
harvested = _harvest_labels(squeezed, ("报价日期", "件数"))
|
||||
self.assertNotIn("报价日期", harvested)
|
||||
card = copy.inquiry_card(
|
||||
work_order_no="WO202610170068",
|
||||
facts={
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "普货",
|
||||
"件数": "10",
|
||||
"毛重": "21",
|
||||
"体积": "1.1*1.2*1.3",
|
||||
"包装方式": "散货",
|
||||
"报价日期": hint,
|
||||
},
|
||||
)
|
||||
self.assertIn("报价日期(非必填):", card)
|
||||
self.assertNotIn("请复制粘贴", card)
|
||||
|
||||
def test_fly_is_air_and_followup_keeps_mode(self) -> None:
|
||||
phase = self._on_text(
|
||||
sender_id="u_fly",
|
||||
@@ -906,7 +938,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("工单状态", sub)
|
||||
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
|
||||
self.assertIn(
|
||||
"请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。",
|
||||
"请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@智报通AIQ”,谢谢。",
|
||||
sub,
|
||||
)
|
||||
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""企微机器人显示名:后台配置 + 新旧都认。"""
|
||||
|
||||
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("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.policy.bot_name import (
|
||||
DEFAULT_BOT_DISPLAY_NAME,
|
||||
REPLY_CODE,
|
||||
at_bot,
|
||||
bot_display_name,
|
||||
bot_mention_names,
|
||||
strip_bot_mention,
|
||||
text_mentions_bot,
|
||||
)
|
||||
from agent.policy.bot_reply import override_catalog, render
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
|
||||
class BotDisplayNameTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
override_catalog(None)
|
||||
|
||||
def test_default_when_empty(self) -> None:
|
||||
override_catalog({})
|
||||
self.assertEqual(bot_display_name(), DEFAULT_BOT_DISPLAY_NAME)
|
||||
self.assertEqual(at_bot(), f"@{DEFAULT_BOT_DISPLAY_NAME}")
|
||||
self.assertIn("询价小助手", bot_mention_names())
|
||||
self.assertIn(DEFAULT_BOT_DISPLAY_NAME, bot_mention_names())
|
||||
|
||||
def test_override_from_admin(self) -> None:
|
||||
override_catalog({REPLY_CODE: "测试助手名"})
|
||||
self.assertEqual(bot_display_name(), "测试助手名")
|
||||
self.assertTrue(text_mentions_bot("@测试助手名 WO1"))
|
||||
self.assertTrue(text_mentions_bot("@询价小助手 WO1"))
|
||||
self.assertEqual(strip_bot_mention("@测试助手名 2"), "2")
|
||||
self.assertEqual(strip_bot_mention("@询价小助手 2"), "2")
|
||||
|
||||
def test_collab_hint_uses_display_name(self) -> None:
|
||||
override_catalog({REPLY_CODE: "智报通AIQ"})
|
||||
hint = copy.collab_hint("WO202610060030")
|
||||
self.assertIn("WO202610060030@智报通AIQ", hint)
|
||||
self.assertNotIn("询价小助手", hint)
|
||||
|
||||
def test_template_bot_name_placeholder(self) -> None:
|
||||
override_catalog(
|
||||
{
|
||||
REPLY_CODE: "智报通AIQ",
|
||||
"inquiring.air_collab_hint": "请发送 {{ticket_no}}@{{bot_name}}",
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
render("inquiring.air_collab_hint", "缺省", ticket_no="WO1"),
|
||||
"请发送 WO1@智报通AIQ",
|
||||
)
|
||||
|
||||
def test_legacy_and_new_strip(self) -> None:
|
||||
override_catalog({REPLY_CODE: "智报通AIQ"})
|
||||
rest_old = strip_bot_mention("工单编号WO202610070026@询价小助手")
|
||||
rest_new = strip_bot_mention("@智报通AIQ WO202610070026")
|
||||
self.assertNotIn("询价小助手", rest_old)
|
||||
self.assertNotIn("智报通AIQ", rest_new)
|
||||
self.assertIn("WO202610070026", rest_old)
|
||||
self.assertIn("WO202610070026", rest_new)
|
||||
|
||||
def test_strip_wecom_lock_instruction(self) -> None:
|
||||
"""锁舱 instructionText 不得把 @机器人 原样带给 TMS。"""
|
||||
override_catalog({REPLY_CODE: "智价通AIQ"})
|
||||
cleaned = strip_bot_mention("@智价通AIQ AIR-OPT01, TH, TH641")
|
||||
self.assertEqual(cleaned, "AIR-OPT01, TH, TH641")
|
||||
self.assertNotIn("@", cleaned)
|
||||
self.assertNotIn("智价通AIQ", cleaned)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,6 +31,21 @@ class BotReplyRenderTests(unittest.TestCase):
|
||||
self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT)
|
||||
self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。")
|
||||
|
||||
def test_multi_won_ack_has_no_lock_note(self) -> None:
|
||||
"""多段已成交只要短确认,不要锁舱括号说明。"""
|
||||
override_catalog(
|
||||
{
|
||||
"deal.recorded_won": (
|
||||
"工单 {{ticket_no}} 已记录为「已成交」。"
|
||||
"(注:已成交/未成交为工单终态,除航线人员可锁舱外请悉知。)"
|
||||
)
|
||||
}
|
||||
)
|
||||
text = copy.deal_ack("已成交", "WO202610170050", business_line="MULTI")
|
||||
self.assertEqual(text, "工单 WO202610170050 已记录为「已成交」。")
|
||||
self.assertNotIn("注", text)
|
||||
self.assertNotIn("锁舱", text)
|
||||
|
||||
def test_override_first_chat(self) -> None:
|
||||
override_catalog({"inquiring.first_chat": "你好,测试问候。"})
|
||||
self.assertEqual(copy.first_chat(), "你好,测试问候。")
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""协同群监听对账:补挂与清孤儿。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class CollabWatchReconcileTests(unittest.TestCase):
|
||||
def test_rewatch_open_missing_from_watch(self) -> None:
|
||||
import agent.jobs.collab_watch_reconcile as mod
|
||||
|
||||
book = MagicMock()
|
||||
book.list_open_collab.return_value = [
|
||||
{
|
||||
"work_order_no": "WO1",
|
||||
"collab_chat_id": "wr_open",
|
||||
"collab_ended": False,
|
||||
"collab_member_ids": ["A", "B"],
|
||||
}
|
||||
]
|
||||
with (
|
||||
patch.object(mod, "_try_lock", return_value=True),
|
||||
patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=[]),
|
||||
patch("agent.channel.wshoto.rooms.watch_collab_room") as watch,
|
||||
patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch,
|
||||
patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value=""),
|
||||
patch.object(mod, "_group_client", return_value=MagicMock()),
|
||||
):
|
||||
stats = mod.reconcile_collab_watch(
|
||||
ledger=book,
|
||||
settings=SimpleNamespace(ledger_backend="http"),
|
||||
dismiss=False,
|
||||
)
|
||||
self.assertEqual(stats["rewatched"], 1)
|
||||
watch.assert_called_with("wr_open", work_order_no="WO1")
|
||||
unwatch.assert_not_called()
|
||||
|
||||
def test_dismiss_ended_in_watch(self) -> None:
|
||||
import agent.jobs.collab_watch_reconcile as mod
|
||||
|
||||
book = MagicMock()
|
||||
book.list_open_collab.return_value = [
|
||||
{
|
||||
"work_order_no": "WO2",
|
||||
"collab_chat_id": "wr_end",
|
||||
"collab_ended": True,
|
||||
"collab_member_ids": ["A", "owner"],
|
||||
}
|
||||
]
|
||||
client = MagicMock()
|
||||
client._appchat_info.return_value = {
|
||||
"owner": "owner",
|
||||
"userlist": ["A", "owner"],
|
||||
}
|
||||
with (
|
||||
patch.object(mod, "_try_lock", return_value=True),
|
||||
patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_end"]),
|
||||
patch("agent.channel.wshoto.rooms.watch_collab_room"),
|
||||
patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch,
|
||||
patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value="WO2"),
|
||||
patch.object(mod, "_group_client", return_value=client),
|
||||
patch("agent.policy.inquiry_copy.group_dissolved", return_value="群已解散"),
|
||||
):
|
||||
stats = mod.reconcile_collab_watch(
|
||||
ledger=book,
|
||||
settings=SimpleNamespace(ledger_backend="http"),
|
||||
dismiss=True,
|
||||
)
|
||||
self.assertEqual(stats["dismissed"], 1)
|
||||
client.dismiss_group.assert_called()
|
||||
unwatch.assert_called_with("wr_end")
|
||||
|
||||
def test_list_empty_does_not_orphan_dismiss_live_redis_wo(self) -> None:
|
||||
"""listOpen 空 + byChat 失败时,Redis 仍有活单工单号 → 禁止清人。"""
|
||||
import agent.jobs.collab_watch_reconcile as mod
|
||||
|
||||
book = MagicMock()
|
||||
book.list_open_collab.return_value = []
|
||||
book.find_by_collab_chat.return_value = None
|
||||
book.get_ticket.return_value = {
|
||||
"work_order_no": "WO202610030039",
|
||||
"collab_ended": False,
|
||||
"collab_chat_id": "wr_live",
|
||||
"collab_member_ids": ["WuJiLin", "LinMianXun"],
|
||||
}
|
||||
client = MagicMock()
|
||||
client._appchat_info.return_value = {
|
||||
"owner": "WuJiLin",
|
||||
"userlist": ["WuJiLin", "LinMianXun", "AI001"],
|
||||
}
|
||||
with (
|
||||
patch.object(mod, "_try_lock", return_value=True),
|
||||
patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_live"]),
|
||||
patch("agent.channel.wshoto.rooms.watch_collab_room"),
|
||||
patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch,
|
||||
patch(
|
||||
"agent.channel.wshoto.rooms.ticket_no_for_room",
|
||||
return_value="WO202610030039",
|
||||
),
|
||||
patch.object(mod, "_group_client", return_value=client),
|
||||
):
|
||||
stats = mod.reconcile_collab_watch(
|
||||
ledger=book,
|
||||
settings=SimpleNamespace(ledger_backend="http"),
|
||||
dismiss=True,
|
||||
)
|
||||
self.assertEqual(stats["dismissed"], 0)
|
||||
client.dismiss_group.assert_not_called()
|
||||
unwatch.assert_not_called()
|
||||
self.assertGreaterEqual(stats["skipped"], 1)
|
||||
|
||||
def test_list_none_blocks_orphan_without_redis_wo(self) -> None:
|
||||
"""listOpen 失败(None) 且无 Redis 工单映射时,也不做孤儿清人。"""
|
||||
import agent.jobs.collab_watch_reconcile as mod
|
||||
|
||||
book = MagicMock()
|
||||
book.list_open_collab.return_value = None
|
||||
book.find_by_collab_chat.return_value = None
|
||||
client = MagicMock()
|
||||
client._appchat_info.return_value = {
|
||||
"owner": "A",
|
||||
"userlist": ["A", "B"],
|
||||
}
|
||||
with (
|
||||
patch.object(mod, "_try_lock", return_value=True),
|
||||
patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_x"]),
|
||||
patch("agent.channel.wshoto.rooms.watch_collab_room"),
|
||||
patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch,
|
||||
patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value=""),
|
||||
patch.object(mod, "_group_client", return_value=client),
|
||||
):
|
||||
stats = mod.reconcile_collab_watch(
|
||||
ledger=book,
|
||||
settings=SimpleNamespace(ledger_backend="http"),
|
||||
dismiss=True,
|
||||
)
|
||||
self.assertEqual(stats["dismissed"], 0)
|
||||
client.dismiss_group.assert_not_called()
|
||||
unwatch.assert_not_called()
|
||||
self.assertGreaterEqual(stats["skipped"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -265,6 +265,64 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
self.assertTrue(any("已记录为「已成交」" in x for x in replies))
|
||||
self.assertIn(wo, "\n".join(replies))
|
||||
|
||||
def test_resume_lost_with_reason_replays_closed_ack(self) -> None:
|
||||
"""未成交且原因已记:继续工单只重播关单回执,不再要原因、不问运输方式。"""
|
||||
from agent.policy.bot_reply import override_catalog
|
||||
|
||||
ledger = MemoryLedger()
|
||||
created = ledger.create_ticket(
|
||||
sender_id="u-lost-done", business_line="AIR", facts=COMPLETE_AIR
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
|
||||
to_status="已报价",
|
||||
)
|
||||
ledger.render_quote(
|
||||
work_order_no=wo,
|
||||
business_line="AIR",
|
||||
facts=COMPLETE_AIR,
|
||||
quote={"total": "USD 1"},
|
||||
)
|
||||
ledger.transition(
|
||||
work_order_no=wo, to_status="未成交", lost_reason="客人比价了"
|
||||
)
|
||||
override_catalog(
|
||||
{
|
||||
"lost.reason_ack": (
|
||||
"已将未成交原因“{{lost_reason}}”记录在后台,"
|
||||
"工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
|
||||
)
|
||||
}
|
||||
)
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
try:
|
||||
phase = flow.resume_historical(
|
||||
sender_id="u-lost-done",
|
||||
work_order_no=wo,
|
||||
reply=reply,
|
||||
text=f"继续工单{wo}",
|
||||
)
|
||||
finally:
|
||||
override_catalog(None)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(
|
||||
replies,
|
||||
[
|
||||
f"已将未成交原因“客人比价了”记录在后台,"
|
||||
f"工单 {wo}已关闭,智能体不再跟进该工单,请悉知。"
|
||||
],
|
||||
)
|
||||
self.assertFalse(any("请补充未成交原因" in x for x in replies))
|
||||
self.assertFalse(any("运输方式" in x for x in replies))
|
||||
|
||||
def test_resume_waiting_lost_reason_saves_sentence(self) -> None:
|
||||
"""等未成交原因时继续工单,下一句要写入原因,不能再要一遍。"""
|
||||
ledger = MemoryLedger()
|
||||
|
||||
@@ -154,6 +154,25 @@ class TestLeaderMatch(unittest.TestCase):
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
# 后台现网选项是「空运全线」,不能只认旧词「全线」
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="空运全线",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="land",
|
||||
routes="陆运全线",
|
||||
is_leader=True,
|
||||
business_line="LAND",
|
||||
facts={"线路类别": "东南亚"},
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
|
||||
@@ -12,7 +12,13 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.schema.field_validate import display_name, validate_required_fields
|
||||
from agent.schema.field_validate import (
|
||||
display_name,
|
||||
harvest_oral_land_vehicle,
|
||||
promote_land_vehicle_from_container,
|
||||
shape_facts_for_chosen_mode,
|
||||
validate_required_fields,
|
||||
)
|
||||
|
||||
|
||||
class LandFieldValidateTest(unittest.TestCase):
|
||||
@@ -83,6 +89,26 @@ class LandFieldValidateTest(unittest.TestCase):
|
||||
self.assertTrue(got["ok"])
|
||||
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
|
||||
|
||||
def test_harvest_45hq_xiangche(self):
|
||||
"""口语 45HQ(13.5厢车)必须写入车型/数量。"""
|
||||
text = (
|
||||
"陆运,苏州市到谅山,中越整车,通信机柜及线缆配件,"
|
||||
"45HQ(13.5厢车),9.11"
|
||||
)
|
||||
got = harvest_oral_land_vehicle(text, {})
|
||||
self.assertIn("45HQ", got.get("车型/数量", ""))
|
||||
self.assertIn("厢车", got.get("车型/数量", ""))
|
||||
|
||||
def test_promote_container_to_land_vehicle(self):
|
||||
"""陆运误入箱型箱量时改写到车型/数量,收口后仍保留。"""
|
||||
promoted = promote_land_vehicle_from_container(
|
||||
{"箱型箱量": "45HQ(13.5厢车)", "品名": "机柜"}
|
||||
)
|
||||
self.assertEqual(promoted.get("车型/数量"), "45HQ(13.5厢车)")
|
||||
shaped = shape_facts_for_chosen_mode(promoted, "LAND")
|
||||
self.assertEqual(shaped.get("车型/数量"), "45HQ(13.5厢车)")
|
||||
self.assertFalse(shaped.get("箱型箱量"))
|
||||
|
||||
def test_quote_date_defaults(self):
|
||||
got = validate_required_fields(
|
||||
facts={
|
||||
|
||||
@@ -44,6 +44,12 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertFalse(cur.get("线路类别"))
|
||||
self.assertEqual(cur.get("运输分类"), "急件")
|
||||
|
||||
def test_cn_vn_ftl_spoken_alias(self):
|
||||
"""中越整车 → 跨境整车 + 东南亚,不能落成国内拼车。"""
|
||||
cur = harvest_land_options("中越整车")
|
||||
self.assertEqual(cur.get("运输类型"), "跨境整车")
|
||||
self.assertEqual(cur.get("线路类别"), "东南亚")
|
||||
|
||||
def test_hk_full_and_lcl_types(self):
|
||||
self.assertEqual(
|
||||
harvest_land_options("中港整车")["运输类型"], "中港整车"
|
||||
|
||||
@@ -130,6 +130,7 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "multi_deal_sent")
|
||||
self.assertEqual(self.sheets, ["SEA", "LAND"])
|
||||
body = self.client.texts[-1]
|
||||
self.assertTrue(body.startswith("@销售甲"), body)
|
||||
self.assertIn("段1:海运", body)
|
||||
self.assertIn("段2:陆运", body)
|
||||
self.assertNotIn("180", body)
|
||||
@@ -138,7 +139,8 @@ class QuoteTests(unittest.TestCase):
|
||||
joined = " ".join(row["value"] for row in rows)
|
||||
self.assertNotIn("180", joined)
|
||||
|
||||
def test_land_person_cannot_fill_sea_segment(self) -> None:
|
||||
def test_fee_name_routes_to_matching_segment(self) -> None:
|
||||
"""正文写海运费就归海运段,不看岗位是不是陆运。"""
|
||||
phase = handle_multi_inbound(
|
||||
flow=self.flow,
|
||||
ticket=self.ticket,
|
||||
@@ -148,7 +150,66 @@ class QuoteTests(unittest.TestCase):
|
||||
sheet_fn=self.sheet,
|
||||
)
|
||||
self.assertEqual(phase, "segment_quoted")
|
||||
self.assertEqual(self.sheets, ["LAND"])
|
||||
self.assertEqual(self.sheets, ["SEA"])
|
||||
|
||||
def test_multi_file_archives_by_filename_mode(self) -> None:
|
||||
"""多段 Excel 按文件名归段并落附件,不再回「没有识别到报价」。"""
|
||||
from agent.policy.multi_group_ops import apply_multi_file
|
||||
|
||||
self.ledger.upsert_staff(
|
||||
{"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"}
|
||||
)
|
||||
# 改成陆运+空运两段,文件名含空运。
|
||||
no = self.ticket.work_order_no
|
||||
self.ledger.update_facts(
|
||||
work_order_no=no,
|
||||
facts={
|
||||
"segments_json": json.dumps(
|
||||
[
|
||||
{"index": 1, "mode": "LAND", "facts": {"起运港": "北京"}},
|
||||
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=no)
|
||||
phase = apply_multi_file(
|
||||
flow=self.flow,
|
||||
ticket=ticket,
|
||||
chat_id="room-1",
|
||||
sender_id="air1",
|
||||
filename=f"{no}空运到港报价单.xlsx",
|
||||
media={"media_id": "media-air-1"},
|
||||
)
|
||||
self.assertEqual(phase, "segment_quoted")
|
||||
self.assertFalse(self.sheets)
|
||||
self.assertTrue(any("报价附件已收到" in t for t in self.client.texts))
|
||||
self.assertFalse(any("没有识别到报价" in t for t in self.client.texts))
|
||||
|
||||
def test_segment_index_beats_wrong_role(self) -> None:
|
||||
"""岗位是空运,正文写段1陆运费 → 仍归陆运段,金额取 1000 不是段号 1。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.policy.multi_group_ops import resolve_quote_segment
|
||||
from agent.policy.multi_segments import Segment
|
||||
|
||||
segs = [
|
||||
Segment(index=1, mode="LAND", facts={}),
|
||||
Segment(index=2, mode="AIR", facts={}),
|
||||
]
|
||||
target, reason = resolve_quote_segment("段1:陆运费 CNY1000", segs, "AIR")
|
||||
self.assertIsNotNone(target)
|
||||
assert target is not None
|
||||
self.assertEqual(target.mode, "LAND")
|
||||
self.assertEqual(target.index, 1)
|
||||
self.assertTrue(reason.startswith("text_index"))
|
||||
quote = extract_product_quote(
|
||||
"@智价通AIQ 段1:陆运费 CNY1000",
|
||||
business_line="LAND",
|
||||
transport_mode="陆运",
|
||||
)
|
||||
self.assertEqual(float(quote.get("total") or 0), 1000.0)
|
||||
self.assertEqual(str((quote.get("fee_rows") or [{}])[0].get("name") or ""), "陆运费")
|
||||
|
||||
def test_requote_refreshes_deal(self) -> None:
|
||||
handle_multi_inbound(
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.schema.sea_options import (
|
||||
canonicalize_sea_class,
|
||||
drop_invented_door_trade_terms,
|
||||
harvest_sea_class,
|
||||
sea_class_prompt,
|
||||
)
|
||||
|
||||
|
||||
class SeaOptionsTest(unittest.TestCase):
|
||||
def test_port_to_port_aliases(self):
|
||||
self.assertEqual(canonicalize_sea_class("Port to Port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
|
||||
glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR"
|
||||
self.assertEqual(canonicalize_sea_class(glued), "Port to Port")
|
||||
self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port")
|
||||
|
||||
def test_other_alias(self):
|
||||
self.assertEqual(canonicalize_sea_class("其他"), "其他")
|
||||
self.assertEqual(canonicalize_sea_class("其它"), "其他")
|
||||
|
||||
def test_door_to_door_rejected(self):
|
||||
self.assertEqual(canonicalize_sea_class("门到门"), "")
|
||||
|
||||
def test_harvest_keeps_current_when_text_has_none(self):
|
||||
got = harvest_sea_class("上海到洛杉矶整柜", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "Port to Port")
|
||||
|
||||
def test_harvest_overrides_from_text(self):
|
||||
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
|
||||
def test_clarify_example_does_not_override_filled_other(self):
|
||||
"""举例行里的 Port to Port 不能盖掉冒号后的其它/其他。"""
|
||||
pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)"
|
||||
got = harvest_sea_class(pasted, {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {})
|
||||
self.assertEqual(other["运输分类"], "其他")
|
||||
|
||||
def test_door_to_door_not_kept_as_trade_terms(self):
|
||||
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
self.assertNotIn("贸易条款", got)
|
||||
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
|
||||
self.assertEqual(kept["贸易条款"], "门到门")
|
||||
|
||||
def test_short_reply_door_or_port_is_trade_term(self):
|
||||
from agent.schema.sea_options import apply_spoken_sea_trade_reply
|
||||
|
||||
door = apply_spoken_sea_trade_reply("门到门", {})
|
||||
self.assertEqual(door.get("贸易条款"), "门到门")
|
||||
port = apply_spoken_sea_trade_reply("港到港", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(port.get("贸易条款"), "港到港")
|
||||
self.assertEqual(port.get("运输分类"), "Port to Port")
|
||||
example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {})
|
||||
self.assertNotIn("贸易条款", example)
|
||||
|
||||
def test_door_inside_long_sentence_is_trade_term(self):
|
||||
"""先报货再选海运:长句里的门到门是贸易条款,港到港仍是运输分类。"""
|
||||
from agent.schema.sea_options import apply_spoken_sea_trade_reply
|
||||
|
||||
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
|
||||
got = apply_spoken_sea_trade_reply(text, {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got.get("贸易条款"), "门到门")
|
||||
self.assertEqual(got.get("运输分类"), "Port to Port")
|
||||
absent = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
|
||||
{"贸易条款": "门到门", "运输分类": "Port to Port"},
|
||||
)
|
||||
self.assertNotIn("贸易条款", absent)
|
||||
self.assertEqual(absent.get("运输分类"), "Port to Port")
|
||||
port_term = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港",
|
||||
{"运输分类": "Port to Port"},
|
||||
)
|
||||
self.assertEqual(port_term.get("贸易条款"), "港到港")
|
||||
self.assertEqual(port_term.get("运输分类"), "Port to Port")
|
||||
invented = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
|
||||
{"贸易条款": "港到港"},
|
||||
)
|
||||
self.assertNotIn("贸易条款", invented)
|
||||
|
||||
def test_wo012_trade_port_class_other(self):
|
||||
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
|
||||
text = (
|
||||
"运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n"
|
||||
"货物数量:15件\n整柜或拼柜:整柜\n箱型箱量:40HQ*1\n"
|
||||
"贸易条款:Port to Port\n运输分类:其它\n报价日期:2026-09-29"
|
||||
)
|
||||
got = harvest_sea_class(text, {"贸易条款": "门到门", "运输分类": "Port to Port"})
|
||||
self.assertEqual(got.get("运输分类"), "其他")
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "运输分类": "其他"})
|
||||
self.assertEqual(got.get("贸易条款"), "Port to Port")
|
||||
self.assertEqual(got.get("运输分类"), "其他")
|
||||
|
||||
def test_trade_line_port_does_not_set_class_when_class_labeled_other(self):
|
||||
text = "贸易条款:Port to Port\n运输分类:其它"
|
||||
self.assertEqual(harvest_sea_class(text, {}).get("运输分类"), "其他")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1970,6 +1970,30 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertNotIn("客户名称", ticket.collab_facts or {})
|
||||
|
||||
def test_handoff_group_still_allows_dissolve(self) -> None:
|
||||
"""转人工后发解散群仍要清人,不能静默丢掉。"""
|
||||
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)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="h-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)
|
||||
|
||||
def test_dissolve_then_cannot_recreate(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
陆运 TMS 出站拼参:分类和中港装载方式不得带出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.schema.tms_land_query import assemble_land_query
|
||||
|
||||
|
||||
class TmsLandQueryTest(unittest.TestCase):
|
||||
def test_outbound_omits_class_and_hk_load(self):
|
||||
facts = {
|
||||
"运输类型": "中港零担/集拼",
|
||||
"线路类别": "东南亚",
|
||||
"运输分类": "急件",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6/1",
|
||||
"通关口岸": "皇岗",
|
||||
}
|
||||
got = assemble_land_query(facts)
|
||||
self.assertTrue(got["ok"])
|
||||
out = got["outbound"]
|
||||
self.assertEqual(out["landTransportType"], "中港")
|
||||
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
|
||||
self.assertNotIn("landRouteCategory", out)
|
||||
self.assertEqual(out["vehicleType"], "9米6")
|
||||
self.assertEqual(str(out["quantity"]), "1")
|
||||
self.assertEqual(out["customsPort"], "皇岗")
|
||||
self.assertNotIn("运输分类", out)
|
||||
self.assertNotIn("landClass", out)
|
||||
self.assertNotIn("hkLoadType", out)
|
||||
self.assertNotIn("中港装载方式", out)
|
||||
|
||||
def test_hk_full_truck_omits_vehicle(self):
|
||||
facts = {
|
||||
"运输类型": "中港整车",
|
||||
"线路类别": "东南亚",
|
||||
"起运港": "广州",
|
||||
"目的港": "香港",
|
||||
"品名": "衣服",
|
||||
}
|
||||
got = assemble_land_query(facts)
|
||||
self.assertTrue(got["ok"])
|
||||
out = got["outbound"]
|
||||
self.assertEqual(out["landTransportType"], "中港")
|
||||
self.assertNotIn("vehicleType", out)
|
||||
self.assertNotIn("quantity", out)
|
||||
|
||||
def test_domestic_ftl_splits_combo(self):
|
||||
facts = {
|
||||
"运输类型": "国内运输整车",
|
||||
"线路类别": "国内长途/零担",
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
"品名": "衣服",
|
||||
"车型/数量": "9米6*2",
|
||||
}
|
||||
out = assemble_land_query(facts)["outbound"]
|
||||
self.assertEqual(out["vehicleType"], "9米6")
|
||||
self.assertEqual(out["quantity"], "2")
|
||||
|
||||
def test_domestic_has_no_customs(self):
|
||||
facts = {
|
||||
"运输类型": "国内运输拼车",
|
||||
"线路类别": "国内长途/零担",
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
"品名": "衣服",
|
||||
"体积": "100CBM",
|
||||
"毛重": "50kg",
|
||||
}
|
||||
out = assemble_land_query(facts)["outbound"]
|
||||
self.assertNotIn("customsPort", out)
|
||||
self.assertIn("volumeCbm", out)
|
||||
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
|
||||
self.assertEqual(out["routeCategory"], "DOMESTIC_LONG_LTL")
|
||||
|
||||
def test_wo011_pinche_city_dist_uses_tms_enums(self):
|
||||
facts = {
|
||||
"运输类型": "国内运输拼车",
|
||||
"线路类别": "国内城配/拖车/打包",
|
||||
"起运港": "泉州市",
|
||||
"目的港": "南京市",
|
||||
"品名": "通信机柜及线缆配件",
|
||||
"体积": "10CBM",
|
||||
"毛重": "10KG",
|
||||
}
|
||||
got = assemble_land_query(facts)
|
||||
self.assertTrue(got["ok"])
|
||||
out = got["outbound"]
|
||||
self.assertEqual(out["landTransportType"], "DOMESTIC_LTL")
|
||||
self.assertEqual(out["routeCategory"], "DOMESTIC_CITY_DIST")
|
||||
self.assertNotIn("landRouteCategory", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -23,7 +23,7 @@ os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.channel.wshoto.client import wshoto_ready
|
||||
from agent.channel.wshoto.client import WshotoError, wshoto_ready
|
||||
from agent.channel.wshoto.parse import archive_record_to_inbound
|
||||
from agent.channel.wshoto.rooms import (
|
||||
list_watched_rooms,
|
||||
@@ -340,6 +340,56 @@ class WshotoPollTests(unittest.TestCase):
|
||||
self.assertEqual(api.calls[0].get("userid"), "")
|
||||
self.assertEqual(api.calls[0].get("target_id"), "wr_room_1")
|
||||
|
||||
def test_hot_room_polled_before_cold_and_rate_limit_stops(self) -> None:
|
||||
"""热群优先;遇 10061 本轮停;游标只推进已扫冷群,不跳过未扫到的。"""
|
||||
from agent.channel.wshoto import rooms as rooms_mod
|
||||
from agent.channel.wshoto.rooms import (
|
||||
load_poll_cursor,
|
||||
mark_room_hot,
|
||||
plan_rooms_for_poll,
|
||||
)
|
||||
|
||||
redis = _redis()
|
||||
for i in range(6):
|
||||
watch_collab_room(f"wr_cold_{i}", redis=redis)
|
||||
watch_collab_room("wr_hot_now", redis=redis)
|
||||
# watch 会标热;单测只留目标热群,模拟「大量冷群 + 一个刚说话的群」。
|
||||
with rooms_mod._hot_lock:
|
||||
rooms_mod._hot_until.clear()
|
||||
mark_room_hot("wr_hot_now")
|
||||
planned = plan_rooms_for_poll(
|
||||
list_watched_rooms(redis=redis),
|
||||
max_cold=2,
|
||||
redis=redis,
|
||||
)
|
||||
self.assertEqual(planned[0], "wr_hot_now")
|
||||
self.assertLessEqual(len(planned), 3)
|
||||
# 规划阶段不得推进游标。
|
||||
self.assertEqual(load_poll_cursor(redis=redis), 0)
|
||||
|
||||
class _RateApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def list_group_messages(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if len(self.calls) >= 2:
|
||||
raise WshotoError(10061, "请求频率过高")
|
||||
return []
|
||||
|
||||
store = MemoryMessageStore()
|
||||
api = _RateApi()
|
||||
cfg = _Cfg()
|
||||
cfg.wshoto_rooms_per_poll = 2
|
||||
cfg.wshoto_room_gap_ms = 0
|
||||
cfg.wshoto_poll_budget_sec = 30
|
||||
out = poll_once(client=api, settings=cfg, redis=redis, store=store)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(api.calls[0].get("target_id"), "wr_hot_now")
|
||||
self.assertLessEqual(len(api.calls), 2)
|
||||
# 热群不算冷游标;限频停在第 1 个冷群之前或之上时,冷游标推进应 < 计划冷群数。
|
||||
self.assertLessEqual(load_poll_cursor(redis=redis), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user