# -*- coding: utf-8 -*- """财务预警口径裁定 R5 落地测试(2026-09-19,任务 finance-20260919-alert-caliber-r5-noperiod) 依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md` §R5(R3 的一致延伸) - **可归期原则**:预警生效判据 = 期间为规整月 `YYYY-MM`;`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警(不可复核 / 不可归责 / 不可驱动行动)。 - **生成侧守卫**:任何生成路径产出的 `kpi_alerts` 行,期间非 `YYYY-MM` 一律不入库(跳过 + 记日志)。 - **R1 与入口无关**:demo 账套(`status='demo'`)在**任何**能产出 `kpi_alerts` 行的路径上 都不得产生业务预警,含按显式 `entity_id` 写入的 **Bot 通道**。 口径唯一事实源仍为 `app/utils/alert_entity_scope.py`:本套件既锁该模块入口函数, 也锁各生成路径的**落库行为**(不入库 = 交付判据)。 类A(可落地守卫,本套件 RED→GREEN 对象):期间已知、可能为伪期间的生成路径 —— 规则引擎(`latest_value.period` 按字符串 desc 取,`2026-H1` 会排在 `2026-09` 之前)、 Bot 写通道(`bot_bridge` / `bot_bridge_v2` 按显式 period 写)。 类B(现状登记,见 `TestR5ClassBStatusQuo`):设计上不携带月度期间的族(集中度/到期未收款/ 资金缺口预测),其期间**无从从库派生**(`kpi_value_id IS NULL`)。 2026-09-19 补充裁定 R5-补(财务,收口两个残口)后本套件的口径变化: - **补1**:差异/趋势/差异反打路径改为**必须落 `kpi_value_id`**(不可归期即跳过), 差异类去重键改 `(entity_id, kpi_id, kpi_value_id, alert_type)` → **差异/趋势已由类B 转入类A**, 其守卫与负对照用例落在 `tests/test_alert_caliber_r5_residual_20260919.py`; - **补2**:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外,不再作为待决策项挂账 → `TestR5ClassBStatusQuo` 由「待产品侧决策」的登记,改为「按补2 保留」的登记。 """ from datetime import datetime import pytest from sqlalchemy.orm import Session from tests.conftest import create_test_kpi from app.models import Entity, KPIValue, KPIAlert from app.utils.alert_entity_scope import ( entity_alert_guard, guard_alert_period, is_attributable_period, ) NOW = datetime(2026, 9, 19, 10, 0) #: 触发形态说明:静态规则 red="<15" + 实测 5.0 → 必然触发红灯(不依赖真实阈值表) RED_EXPR = "<15" def _ent(db, eid, status, name=None): db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status)) db.commit() def _kpi_value(db, kpi, period, value, entity_id=1): db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value)) db.commit() def _alert_rule(db, kpi_id, entity_id=1, rule_type="static", params=None): from app.api.alert_rules import AlertRule AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True) r = AlertRule(entity_id=entity_id, kpi_id=kpi_id, rule_type=rule_type, enabled=1, params=params if params is not None else {"red": RED_EXPR}) db.add(r) db.commit() return r # ─────────────────── R5 单一事实源入口(守卫语义) ─────────────────── class TestR5SingleSourceGuard: def test_is_attributable_period(self): """可归期 = 规整月 YYYY-MM;NULL / 伪期间 / 非法月 / 非月粒度一律不可归期""" for ok in ("2026-09", "2026-04", "2025-12"): assert is_attributable_period(ok) is True, ok for bad in (None, "", "2026-H1", "2026-H2", "2025-FY", "2025", "2026-13", "2026-9", "2026-09-01", "2026-09-19", "本月"): assert is_attributable_period(bad) is False, bad def test_guard_alert_period_semantics(self): """guard 返回 True=放行入库;False=跳过(调用点不得落库)""" assert guard_alert_period("2026-09", source="unit-test") is True assert guard_alert_period(None, source="unit-test") is False assert guard_alert_period("2026-H1", source="unit-test") is False def test_entity_alert_guard_demo_vs_active(self, db: Session): """R1 通用守卫:demo 账套 False(不产生业务预警),active 账套 True""" _ent(db, 1, "active") _ent(db, 6, "demo") assert entity_alert_guard(db, 6, source="unit-test") is False assert entity_alert_guard(db, 1, source="unit-test") is True assert entity_alert_guard(db, None, source="unit-test") is True, "None → 默认账套 1" # ─────────────────── 类A-1:规则引擎路径(伪期间不得入库) ─────────────────── class TestR5RuleEnginePath: def _pseudo_latest(self, db): """KPI 的最新(字符串排序最大)值是伪期间 2026-H1 → 旧码会据此落库一条 2026-H1 预警""" _ent(db, 1, "active") k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k, "2026-06", 50.0) _kpi_value(db, k, "2026-H1", 5.0) # '2026-H1' 字符串序 > '2026-06' → 被取为 latest _alert_rule(db, k.id) return k def test_pseudo_period_latest_not_inserted(self, db: Session): """latest_value=2026-H1 → 该 kpi_alerts 行不入库(R5 生成侧守卫)""" from app.api.alert_rules import run_all_alert_checks self._pseudo_latest(db) before = db.query(KPIAlert).count() r = run_all_alert_checks(db=db, entity_id=1) db.expire_all() assert db.query(KPIAlert).count() == before, "期间非 YYYY-MM → 不入库" assert r["generated"] == 0 assert r.get("skipped_non_month_period") == 1 assert r.get("skipped_non_month_period_kpis") == ["F_REVENUE[2026-H1]"] def test_month_period_still_inserted(self, db: Session): """正对照:规整月期间照常入库(守卫只拦「不可归期」,不误伤正常预警)""" from app.api.alert_rules import run_all_alert_checks _ent(db, 1, "active") k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k, "2026-09", 5.0) _alert_rule(db, k.id) r = run_all_alert_checks(db=db, entity_id=1) db.expire_all() rows = db.query(KPIAlert).all() assert r["generated"] == 1 and len(rows) == 1 assert rows[0].kpi_value_id is not None, "规整月预警带 kpi_value 关联(期间可归期)" # ─────────────────── 类A-2 / R1:Bot 写通道 ─────────────────── class TestR5BotWritePaths: def _bot_kpi(self, db, entity_id=1, code="F_REVENUE"): _ent(db, 1, "active") _ent(db, 6, "demo", "演示企业(虚构数据)") return create_test_kpi(db, kpi_code=code, kpi_name="营业收入", entity_id=entity_id, unit="%", threshold_red=RED_EXPR) def test_bot_bridge_pseudo_period_no_alert(self, db: Session): """bot_bridge 写端点:period=2026-H1 → 不产生预警行(值照写,不动 kpi_values 口径)""" from app.api.bot_bridge import bot_kpi_value_with_check k = self._bot_kpi(db) _alert_rule(db, k.id) before = db.query(KPIAlert).count() r = bot_kpi_value_with_check( data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-H1", "entity_id": 1}, db=db, bot={"name": "test-bot"}) db.expire_all() assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库" assert r["alerts"] == [] assert r.get("skipped_non_month_period") == 1 def test_bot_bridge_demo_entity_no_alert(self, db: Session): """R1:bot_bridge 写 demo 账套(显式 entity_id=6)→ 不产生业务预警""" from app.api.bot_bridge import bot_kpi_value_with_check k = self._bot_kpi(db, entity_id=6, code="DEMO_REVENUE") _alert_rule(db, k.id, entity_id=6) before = db.query(KPIAlert).count() r = bot_kpi_value_with_check( data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-09", "entity_id": 6}, db=db, bot={"name": "test-bot"}) db.expire_all() assert db.query(KPIAlert).count() == before assert r["alerts"] == [] and r.get("reason") == "R1_demo_entity_excluded" def test_bot_bridge_v2_pseudo_period_no_alert(self, db: Session): """bot_bridge_v2(mpm-result):period=2026-H1 → 不产生预警行""" from app.api.bot_bridge_v2 import receive_mpm_result from app.models import MpmResult MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True) _ent(db, 1, "active") create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=1, unit="%") before = db.query(KPIAlert).count() r = receive_mpm_result( data={"entity_id": 1, "period": "2026-H1", "results": {"channel_rebate_rate": 80.0}}, bridge_bot="test-bridge", db=db) db.expire_all() assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库" assert r["alerts_triggered"] == 0 assert r.get("skipped_non_month_period") is True def test_bot_bridge_v2_demo_entity_no_alert(self, db: Session): """R1:bot_bridge_v2 写 demo 账套 → 不产生业务预警(规整月也不放行)""" from app.api.bot_bridge_v2 import receive_mpm_result from app.models import MpmResult MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True) _ent(db, 6, "demo", "演示企业(虚构数据)") create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=6, unit="%") before = db.query(KPIAlert).count() r = receive_mpm_result( data={"entity_id": 6, "period": "2026-09", "results": {"channel_rebate_rate": 80.0}}, bridge_bot="test-bridge", db=db) db.expire_all() assert db.query(KPIAlert).count() == before assert r["alerts_triggered"] == 0 and r.get("reason") == "R1_demo_entity_excluded" # ─────────────────── 类B:无月度期间族(现状登记,待产品侧) ─────────────────── class TestR5ClassBStatusQuo: """类B 现状登记(非守卫断言)——1b 实测证据:这些生成路径产出的行**期间无从从库派生**。 裁定演进(2026-09-19 §补充裁定 R5-补2):类B「无月度期间族」**不屏蔽(现状保留)**, 理由 = 驱动量是日期/占比/趋势,可行动性不以月度期间为前提(属 R5 边界条款所指 「无期间合法预警」业务需求);屏蔽会一次性灭掉「到期未收款」等既有能力(存量 423 行族) 并触发既有套件失败 → **登记为已知例外,不再作为待决策项挂账**。 ⚠ 差异路径原属本类,**已由补1 转入类A**(必须落 kpi_value_id,不可归期即跳过)—— 其守卫与负对照用例见 `tests/test_alert_caliber_r5_residual_20260919.py`, 本类不再保留其「无期间关联」的现状断言(该断言与补1 冲突,已随裁定删除)。 类B 余族的现状登记同样落在该新套件(`TestR5ResidualClassBStatusQuo`)。 """ # 原 `test_deviation_path_row_has_no_derivable_period`(差异路径「无期间关联」现状断言) # 已随 §补充裁定 R5-补1 删除:差异路径转为类A,必须落 kpi_value_id,不可归期即跳过。 # 替代用例(含负对照:旧逻辑取 NULL / 新逻辑取该期间行)见 # tests/test_alert_caliber_r5_residual_20260919.py::TestR5ResidualDeviationLinkage(6 例)。