员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-28 19:48:47 +08:00
co-authored by Cursor
parent 2b528758b4
commit 56ba8e93b5
62 changed files with 2960 additions and 793 deletions
+33
View File
@@ -12,6 +12,7 @@ from agent.policy.deal_followup import (
SHANGHAI,
add_workday_hours,
air_route_keys,
air_staff_route_matches,
is_workday,
leader_matches,
next_followup_at,
@@ -193,5 +194,37 @@ class TestLeaderMatch(unittest.TestCase):
)
class TestAirStaffRouteMatches(unittest.TestCase):
"""群 @ 航线:按负责线路过滤,不要求 isLeader。"""
def test_ehu_ord_pair_and_all_line(self) -> None:
facts = {"起运港": "EHU", "目的港": "ORD"}
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
self.assertTrue(air_staff_route_matches(routes="空运全线", facts=facts))
self.assertFalse(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
self.assertFalse(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
def test_dest_only_matches_pair_suffix(self) -> None:
facts = {"目的港": "KUL"}
self.assertTrue(air_staff_route_matches(routes="CGO-KUL,SZX-KUL", facts=facts))
self.assertTrue(air_staff_route_matches(routes="HKG-KUL,NKG-KUL", facts=facts))
self.assertFalse(air_staff_route_matches(routes="EHU-ORD", facts=facts))
self.assertFalse(air_staff_route_matches(routes="CGO-BLR,CGO-MAA", facts=facts))
def test_non_leader_still_matches(self) -> None:
# 群 @ 与成交推负责人不同:非负责人只要线路对也要 @
facts = {"起运港": "EHU", "目的港": "ORD"}
self.assertTrue(air_staff_route_matches(routes="EHU-ORD", facts=facts))
self.assertFalse(
leader_matches(
role_code="air",
routes="EHU-ORD",
is_leader=False,
business_line="AIR",
facts=facts,
)
)
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -364,6 +364,53 @@ class TestDealOutcome(unittest.TestCase):
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
def test_orphan_lost_wait_clears_when_ticket_missing(self) -> None:
"""清数后主账无单:等原因书签必须丢掉,不能死循环「请再发一句」。"""
from agent.handlers.text_inquiry import _flow_waiting_lost_reason
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.policy.land_text_flow import LandTextInquiryFlow
book = MemoryLedger()
air = AirTextInquiryFlow(book)
sea = SeaTextInquiryFlow(book)
land = LandTextInquiryFlow(book)
ghost = "WO202610090041"
land._save(
FlowSession(
sender_id="YangHuiMing",
thread_id="YangHuiMing",
phase="wait_lost_reason",
business_line="LAND",
work_order_no=ghost,
status="未成交",
)
)
self.assertIsNotNone(land.session_of("YangHuiMing"))
# 路由前应 scrub 孤儿,不再交给陆运等原因
self.assertIsNone(_flow_waiting_lost_reason("YangHuiMing", sea, air, land))
self.assertIsNone(land.session_of("YangHuiMing"))
# 若仍走到 capture:也应清书签并提示可新询价
land._save(
FlowSession(
sender_id="YangHuiMing",
thread_id="YangHuiMing",
phase="wait_lost_reason",
business_line="LAND",
work_order_no=ghost,
status="未成交",
)
)
replies: list[str] = []
phase = land._capture_lost_reason(
land.session_of("YangHuiMing"),
"客户取消订单",
lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "orphan_cleared")
self.assertTrue(any("已不在系统中" in t for t in replies))
self.assertIsNone(land.session_of("YangHuiMing"))
def test_sea_private_next_text_is_lost_reason(self) -> None:
flow = SeaTextInquiryFlow(self.ledger)
flow._save(
+14 -1
View File
@@ -300,10 +300,22 @@ class TestPoll(unittest.TestCase):
"name": "吴季林",
"roleCode": "air",
"status": "active",
"routes": "空运全线",
},
{
"wecomId": "air2",
"name": "KUL专员",
"roleCode": "air",
"status": "active",
"routes": "CGO-KUL,SZX-KUL",
},
]
)
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
created = book.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运港": "EHU", "目的港": "ORD"},
)
wo = created["work_order_no"]
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
@@ -331,6 +343,7 @@ class TestPoll(unittest.TestCase):
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
)
self.assertNotIn("相关人员", groups[0]["content"])
self.assertNotIn("KUL专员", groups[0]["content"])
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
+51
View File
@@ -721,5 +721,56 @@ class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
self.assertNotIn("友通达", blob)
class QuoteIdNotOriginTests(unittest.TestCase):
"""识图把报价编号错贴到起运地时必须丢掉,不能当港口查价。"""
def test_quote_no_labeled_as_origin_dropped(self) -> None:
text = (
"报价编号:QT202610220088\n"
"起运地:QT202610220088\n"
"目的地:吉隆坡\n"
"品名:茶叶\n"
"件数:21件\n"
"毛重:11KG\n"
"体积:1.1*2.1*2.2\n"
"包装类型:托盘\n"
"运输方式:空运"
)
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertNotIn("起运港", facts)
self.assertEqual(facts.get("目的港"), "吉隆坡")
def test_work_order_as_origin_dropped(self) -> None:
text = (
"起运地:WO202610220001\n"
"目的地:吉隆坡\n"
"品名:茶叶\n"
"件数:10件\n"
"毛重:11KG\n"
"体积:1CBM\n"
"包装方式:托盘\n"
"空运"
)
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertNotIn("起运港", facts)
self.assertEqual(facts.get("目的港"), "吉隆坡")
def test_real_city_origin_kept(self) -> None:
text = (
"报价编号:QT202610220088\n"
"起运地:南京\n"
"目的地:吉隆坡\n"
"品名:茶叶\n"
"件数:10件\n"
"毛重:11KG\n"
"体积:1CBM\n"
"包装方式:托盘\n"
"空运"
)
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertEqual(facts.get("起运港"), "南京")
self.assertEqual(facts.get("目的港"), "吉隆坡")
if __name__ == "__main__":
unittest.main()
@@ -41,10 +41,12 @@ class LandCollabFieldsTest(unittest.TestCase):
self.assertIn("HS编码", keys)
self.assertNotIn("车型/数量", keys)
def test_hk_ftl_has_vehicle(self):
def test_hk_ftl_vehicle_is_inquiry_not_collab(self):
"""中港整车车型/数量已改到询价必填,协同不再要。"""
keys = land_collab_fields("中港整车", "中港/中亚/中欧")
self.assertIn("车型/数量", keys)
self.assertNotIn("车型/数量", keys)
self.assertEqual(keys[0], "客户名称")
self.assertIn("HS编码", keys)
def test_unlisted_pair_empty(self):
self.assertEqual(land_collab_fields("国内运输拼车", "东南亚"), ())
+7 -6
View File
@@ -53,11 +53,11 @@ class LandCopyTest(unittest.TestCase):
)
self.assertIn("通关口岸(非必填):", text)
def test_hk_lcl_shows_vehicle_not_load_field(self):
def test_hk_lcl_hides_vehicle_not_load_field(self):
text = copy.land_confirm_card(
{
"运输类型": "中港零担/集拼",
"线路类别": "东南亚",
"线路类别": "中港/中亚/中欧",
"运输分类": "急件",
"起运港": "广州",
"目的港": "香港",
@@ -67,24 +67,25 @@ class LandCopyTest(unittest.TestCase):
}
)
self.assertIn("运输类型(必填):中港零担/集拼", text)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("车型/数量", text)
self.assertNotIn("中港装载方式", text)
self.assertIn("通关口岸(非必填)", text)
def test_hk_full_truck_hides_vehicle(self):
def test_hk_full_truck_shows_vehicle(self):
text = copy.land_confirm_card(
{
"运输类型": "中港整车",
"线路类别": "东南亚",
"线路类别": "中港/中亚/中欧",
"运输分类": "急件",
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
"报价日期": "2026-09-17",
}
)
self.assertIn("运输类型(必填):中港整车", text)
self.assertNotIn("车型/数量", text)
self.assertIn("车型/数量(必填):9米6/1", text)
self.assertNotIn("中港装载方式", text)
def test_domestic_ftl_one_field(self):
@@ -31,24 +31,25 @@ class LandFieldValidateTest(unittest.TestCase):
self.assertFalse(got["ok"])
self.assertIn("体积", got["missing"])
def test_hk_lcl_still_needs_vehicle(self):
def test_hk_lcl_skips_vehicle(self):
"""中港零担/集拼:询价不要车型/数量。"""
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
},
business_line="LAND",
land_subtype="中港零担/集拼",
)
self.assertIn("车型/数量", got["missing"])
self.assertNotIn("数量", got["missing"])
self.assertNotIn("车型", got["missing"])
self.assertTrue(got["ok"])
self.assertNotIn("车型/数量", got["missing"])
self.assertNotIn("体积", got["missing"])
self.assertNotIn("毛重", got["missing"])
def test_hk_full_truck_skips_vehicle(self):
got = validate_required_fields(
def test_hk_full_truck_requires_vehicle(self):
"""中港整车:询价必填车型/数量。"""
missing = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
@@ -57,8 +58,20 @@ class LandFieldValidateTest(unittest.TestCase):
business_line="LAND",
land_subtype="中港整车",
)
self.assertFalse(missing["ok"])
self.assertIn("车型/数量", missing["missing"])
got = validate_required_fields(
facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"车型/数量": "9米6/1",
},
business_line="LAND",
land_subtype="中港整车",
)
self.assertTrue(got["ok"])
self.assertNotIn("车型/数量", got["missing"])
self.assertEqual(got["facts"]["车型/数量"], "9米6/1")
def test_domestic_ftl_one_field(self):
got = validate_required_fields(
@@ -112,25 +112,22 @@ class LandTextInquiryFrontTests(unittest.TestCase):
self.assertEqual(sess.facts.get("运输类型"), "国内运输拼车")
self.assertEqual(sess.facts.get("线路类别"), "东南亚")
def test_hk_lcl_asks_vehicle(self) -> None:
def test_hk_lcl_skips_vehicle(self) -> None:
"""中港零担/集拼:不补问车型/数量,齐字段后直接出核对卡。"""
phase = self.flow.on_text(
sender_id="u1",
text="中港零担/集拼 东南亚 急件",
text="中港零担/集拼 中港/中亚/中欧 急件",
reply=self._reply,
injected_facts={
"起运港": "广州",
"目的港": "香港",
"品名": "衣服",
"体积": "10CBM",
"毛重": "50kg",
},
injected_mode="LAND",
)
self.assertEqual(phase, "clarify")
self.assertIn("车型/数量(必填)", self._joined())
self.assertEqual(phase, "wait_confirm")
self.assertNotIn("车型/数量", self._joined())
self.assertIn("通关口岸(非必填)", self._joined())
self.assertNotIn("\n数量:", self._joined())
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
self.assertEqual(self.ledger.create_calls, 0)
def test_hk_full_truck_skips_vehicle(self) -> None:
+227
View File
@@ -22,6 +22,7 @@ from agent.schema.quote_adjust import (
apply_adjust,
effective_quote,
extract_adjust,
parse_money,
private_session_can_adjust,
total_price_label,
)
@@ -48,6 +49,151 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertEqual(str(got.quote["total"]), "1300.00")
self.assertFalse(got.currency_hint)
def test_increase_to_percent_is_set_not_up(self) -> None:
"""「运输费用增加到110%」= 原价×1.1,不是上调 110%(×2.1)。"""
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
ext = extract_adjust(
"运输费用增加到110%",
fee_names=["运输费"],
allow_model=False,
)
self.assertTrue(ext.is_adjust)
self.assertEqual(len(ext.items), 1)
self.assertEqual(ext.items[0].kind, "percent")
self.assertEqual(ext.items[0].direction, "set")
self.assertEqual(ext.items[0].value, 110.0)
got = apply_adjust(src, ext, business_line="LAND", tms_cost="7500")
self.assertTrue(got.ok)
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00")
self.assertEqual(str(got.quote["total"]), "CNY 8250.00")
self.assertIn("调到 110%", got.applied[0]["how"])
def test_air_unit_price_percent_keeps_per_kg_not_line_total(self) -> None:
"""空运「单价增加到110%」:单价 19→20.9,行金额=20.9×162;禁止把合计写进单价。"""
src = {
"currency": "CNY",
"total": "3438",
"fee_rows": [
{
"name": "空运费",
"amount": "3078",
"currency": "CNY",
"unit": "KG",
"unit_price": "19",
"quantity": "162",
},
{
"name": "人工费",
"amount": "200",
"currency": "CNY",
"unit": "人",
"unit_price": "200",
"quantity": "1",
},
{
"name": "打板费",
"amount": "160",
"currency": "CNY",
"unit": "个",
"unit_price": "160",
"quantity": "1",
},
],
}
ext = extract_adjust(
"空运费单价增加到110%/KG",
fee_names=["空运费", "人工费", "打板费"],
allow_model=False,
)
got = apply_adjust(src, ext, business_line="AIR")
self.assertTrue(got.ok)
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
self.assertEqual(air["amount"], "3385.80")
self.assertEqual(str(air.get("unit_price") or ""), "20.90")
self.assertNotEqual(str(air.get("unit_price") or ""), "3385.80")
def test_air_unit_percent_uses_chargeable_weight_when_qty_missing(self) -> None:
"""费用行无 quantity 时,用工单计费重:单价15→16.5,禁止把行合计写进单价。"""
src = {
"currency": "CNY",
"total": "38757.75",
"chargeableWeightKg": "2583.85",
"fee_rows": [
{
"name": "空运费",
"amount": "38757.75",
"currency": "CNY",
"unit": "KG",
"unit_price": "15",
"unitPrice": "15",
}
],
"selected_air_option": {
"chargeableWeightKg": 2583.85,
"airlineCode": "TH",
"feeItems": [
{
"chargeName": "空运费",
"unitPrice": 15,
"amount": 38757.75,
"unit": "KG",
"currency": "CNY",
}
],
},
}
ext = extract_adjust(
"空运费单价增加到110%/KG",
fee_names=["空运费"],
allow_model=False,
)
got = apply_adjust(src, ext, business_line="AIR")
self.assertTrue(got.ok)
air = next(r for r in got.quote["fee_rows"] if r["name"] == "空运费")
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
self.assertNotEqual(str(air.get("unit_price") or ""), "42633.53")
amt, _ = parse_money(air.get("amount"))
self.assertAlmostEqual(float(amt or 0), 42633.525, places=2)
def test_model_up_coerced_when_text_says_to_percent(self) -> None:
"""模型误抽成 up 时,按原话「增加到X%」纠正为 set。"""
src = _quote(("运输费", "7500", "CNY"), total="7500")
injected = {
"is_adjust": True,
"wants_total": False,
"items": [{"fee_name": "运输费", "kind": "percent", "direction": "up", "value": 110}],
}
ext = extract_adjust(
"运输费用增加到110%",
fee_names=["运输费"],
injected=injected,
allow_model=False,
)
self.assertEqual(ext.items[0].direction, "set")
got = apply_adjust(src, ext, business_line="LAND")
self.assertEqual(got.quote["fee_rows"][0]["amount"], "8250.00")
def test_to_percent_phrase_variants(self) -> None:
"""调整到/涨到/升到/增至/变为/上浮到 X% 一律按改到原价比例。"""
src = _quote(("运输费", "CNY 7500.00", "CNY"), total="CNY 7500.00")
phrases = (
"运输费调整到110%",
"运输费涨到110%",
"运输费升到110%",
"运输费增至110%",
"运输费变为110%",
"运输费上浮到110%",
)
for text in phrases:
with self.subTest(text=text):
ext = extract_adjust(text, fee_names=["运输费"], allow_model=False)
self.assertTrue(ext.is_adjust, text)
self.assertEqual(ext.items[0].kind, "percent", text)
self.assertEqual(ext.items[0].direction, "set", text)
got = apply_adjust(src, ext, business_line="LAND")
self.assertTrue(got.ok, text)
self.assertEqual(got.quote["fee_rows"][0]["amount"], "CNY 8250.00", text)
def test_percent_down_and_target_in_one_sentence(self) -> None:
src = _quote(("提货费", "1000", "CNY"), ("送货费", "200", "CNY"), total="1200")
ext = extract_adjust("提货费减 5%,送货费提升到 180 元", fee_names=["提货费", "送货费"])
@@ -573,6 +719,87 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertTrue(copy.quote_is_tms(sess.tms_quote))
self.assertFalse(copy.quote_is_tms(sess.quote))
def test_private_air_adjust_emits_facts_so_cargo_not_blank(self) -> None:
"""空运私聊调价出卡必须带 sess.facts,否则起运地/品名会整段变「-」。"""
from agent.policy.quote_adjust_ops import try_private_adjust
class _Sess:
status = "已报价"
phase = "wait_collab"
work_order_no = "WO202610280072"
business_line = "AIR"
collab_chat_id = ""
wait_version = 1
first_or_same = "first"
history_work_order_no = ""
facts = {
"起运地": "南京",
"目的地": "吉隆坡",
"品名": "普货",
"件数": "15",
"毛重": "2403",
"体积": "15.5",
"包装类型": "托盘",
}
quote = {
"currency": "CNY",
"total": "38757.75",
"chargeableWeightKg": "2583.85",
"fee_rows": [
{
"name": "空运费",
"amount": "38757.75",
"currency": "CNY",
"unit": "KG",
"unit_price": "15",
}
],
}
tms_quote = {}
class _Flow:
def __init__(self) -> None:
self.emitted = None
class _Ledger:
def upsert_product_quote(self, **kwargs):
return {"ok": True}
self.ledger = _Ledger()
def _save(self, sess) -> None:
return None
def _emit_air_hit(self, reply, **kwargs) -> None:
self.emitted = kwargs
sess = _Sess()
flow = _Flow()
out = try_private_adjust(
flow,
sess,
"空运费单价增加到110%/KG",
lambda *_a, **_k: None,
injected_adjust={
"is_adjust": True,
"items": [
{
"fee_name": "空运费",
"kind": "percent",
"direction": "set",
"value": 110,
}
],
},
)
self.assertEqual(out, "wait_collab")
self.assertIsNotNone(flow.emitted)
facts = dict(flow.emitted.get("facts") or {})
self.assertEqual(facts.get("起运地"), "南京")
self.assertEqual(facts.get("品名"), "普货")
air = next(r for r in sess.quote["fee_rows"] if r["name"] == "空运费")
self.assertEqual(str(air.get("unit_price") or ""), "16.50")
def test_group_brief_keeps_tms_when_current_is_adjust(self) -> None:
"""拉群摘要:TMS 段必须是原价,调后价另写销售调价,禁止替换。"""
tms = _quote(("运输费", "110.00", "CNY"), total="110.00")
@@ -1427,6 +1427,64 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["增值费"].get("project") or ""), "起运港")
self.assertEqual(str(rows["增值费"].get("amount") or ""), "1000")
def test_air_origin_fee_unit_ci_keeps_qty_and_amount(self) -> None:
"""WO081:打板费单位「次」必须认出数量100/金额1000,不能落成数量1、金额=单价。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"@智价通AIQ 起运港,打板费,次,单价CNY10,数量100,金额CNY1000",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("打板费", rows)
self.assertEqual(str(rows["打板费"].get("unit") or ""), "次")
self.assertEqual(str(rows["打板费"].get("unit_price") or ""), "10")
self.assertEqual(str(rows["打板费"].get("quantity") or ""), "100")
self.assertEqual(str(rows["打板费"].get("amount") or ""), "1000")
self.assertEqual(str(rows["打板费"].get("project") or ""), "起运港")
def test_air_origin_fee_unit_ren_keeps_qty_and_amount(self) -> None:
"""单位「人」同样要能抽出数量与金额。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港,人工费,人,单价CNY50,数量2,金额CNY100",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("人工费", rows)
self.assertEqual(str(rows["人工费"].get("unit") or ""), "人")
self.assertEqual(str(rows["人工费"].get("quantity") or ""), "2")
self.assertEqual(str(rows["人工费"].get("amount") or ""), "100")
def test_air_origin_fee_unit_slash_ci_ren(self) -> None:
"""单位表「次/人」整段保留,不能拆成只认「次」。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港,操作费,次/人,单价CNY20,数量3,金额CNY60",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("操作费", rows)
self.assertEqual(str(rows["操作费"].get("unit") or ""), "次/人")
self.assertEqual(str(rows["操作费"].get("quantity") or ""), "3")
self.assertEqual(str(rows["操作费"].get("amount") or ""), "60")
def test_air_origin_fee_unit_hour_per_person(self) -> None:
"""单位表「小时/人」整段保留。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"起运港,仓储费,小时/人,单价CNY30,数量4,金额CNY120",
business_line="AIR",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("仓储费", rows)
self.assertEqual(str(rows["仓储费"].get("unit") or ""), "小时/人")
self.assertEqual(str(rows["仓储费"].get("quantity") or ""), "4")
self.assertEqual(str(rows["仓储费"].get("amount") or ""), "120")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
+78 -1
View File
@@ -226,7 +226,8 @@ class WshotoPollTests(unittest.TestCase):
self.assertTrue(out["ok"])
call = api.calls[0]
self.assertEqual(call.get("msg_time_end"), 1_800_000_000)
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180)
# 新群回看上限见 poll._NEW_ROOM_LOOKBACK_SEC(3600),须盖过存档延迟。
self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 3600)
def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None:
store = MemoryMessageStore()
@@ -317,6 +318,82 @@ class WshotoPollTests(unittest.TestCase):
self.assertEqual(b, 0)
self.assertEqual(skipped, 1)
def test_room_seq_gap_backfill_keeps_dissolve(self) -> None:
"""
现网事故:增量只先到「撤回」高 seq,游标一步跳到 9899,
中间的「解散群」再也拉不回来。跳号时必须时间窗补洞再推进游标。
"""
store = MemoryMessageStore()
redis = _redis()
room = "wr_gap_dissolve"
watch_collab_room(room, redis=redis)
save_last_seq(f"room:{room}", 9892, redis=redis)
all_rows = [
{
"msgid": "m9897",
"seq": 9897,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "text",
"content": "随便说一句",
"msgtime": 1_792_546_051_000,
},
{
"msgid": "m9898",
"seq": 9898,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "text",
"content": "解散群",
"msgtime": 1_792_546_055_000,
},
{
"msgid": "m9899",
"seq": 9899,
"from": "ShiChangChang",
"room_id": room,
"msgtype": "revoke",
"content": "[撤回消息]",
"msgtime": 1_792_546_059_000,
},
]
class _GapApi:
def __init__(self) -> None:
self.calls: list[dict] = []
def list_group_messages(self, **kwargs):
self.calls.append(dict(kwargs))
last = int(kwargs.get("last_seq") or 0)
time_start = int(kwargs.get("msg_time_start") or 0)
# 模拟管家增量晚到:先只吐撤回;时间窗才能看到中间「解散群」。
if time_start > 0:
return [r for r in all_rows if int(r["seq"]) > last]
if last > 0:
return [r for r in all_rows if int(r["seq"]) == 9899 and int(r["seq"]) > last]
return list(all_rows)
cfg = _Cfg()
cfg.wshoto_room_gap_ms = 0
out = poll_once(
client=_GapApi(),
settings=cfg,
redis=redis,
store=store,
now=lambda: 1_792_546_100,
)
self.assertTrue(out["ok"])
self.assertGreaterEqual(out["enqueued"], 1)
self.assertEqual(load_last_seq(f"room:{room}", redis=redis), 9899)
claimed = []
while True:
item = store.claim_inbound()
if item is None:
break
claimed.append(item.message.content)
self.assertIn("解散群", claimed)
def test_watched_rooms_preferred_without_seat_userid(self) -> None:
store = MemoryMessageStore()
redis = _redis()