# -*- coding: utf-8 -*- """财务预警口径裁定 R1–R4 落地测试(2026-09-19) 依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」) · R1 —— `entities.status='demo'` 的账套不参与预警生成/遍历(demo 仅演示展示); · R2 —— 预警遍历期间 = 当月往前 6 个自然月(含当月),更早期间不再新生成; · R3 —— 仅规整月期间 `YYYY-MM` 参与,`H1/H2/FY` 等伪期间一律不参与; · R4 —— 生成与推送解耦:`notification_channels` 为空时仅入库、不报错、不阻断。 口径唯一事实源:`app/utils/alert_entity_scope.py`(本套件同时锁该模块的入口函数)。 """ 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, NotificationChannel from app.utils.alert_entity_scope import ( ALERT_EXCLUDED_ENTITY_STATUSES, ALERT_WINDOW_MONTHS, excluded_entity_ids, is_excluded_entity, is_month_period, kpi_excluded, period_in_window, window_lower_period, ) from scripts.alert_generator import generate_and_push, run_alert_check #: 固定「当月」= 2026-09 → 窗口 = 2026-04 ~ 2026-09(口径复算不依赖真实时钟) NOW = datetime(2026, 9, 19, 10, 0) RED_EXPR = "<58.4" # 0<实测<10 比值形态不触发 dimension_suspect(见裁定2a 降级门) 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 _demo_and_active(db): _ent(db, 1, "active", "陕西酣客文化传媒") _ent(db, 6, "demo", "演示企业(虚构数据)") # ───────────────────────── R1:demo 账套排除 ───────────────────────── class TestR1DemoEntityExcluded: def test_excluded_ids_only_demo(self, db: Session): _ent(db, 1, "active") _ent(db, 3, "demo") _ent(db, 4, "demo") _ent(db, 5, "demo") _ent(db, 6, "demo") assert excluded_entity_ids(db) == {3, 4, 5, 6} assert is_excluded_entity(db, 6) is True assert is_excluded_entity(db, 1) is False assert is_excluded_entity(db, None) is False, "None → 默认账套 1(不被排除)" assert ALERT_EXCLUDED_ENTITY_STATUSES == ("demo",) def test_demo_entity_missing_table_fail_open(self, db: Session, monkeypatch): """口径读取失败时 fail-open(不阻断预警主流程),返回空集""" import app.utils.alert_entity_scope as scope def _boom(*a, **kw): raise RuntimeError("simulated db error") monkeypatch.setattr(db, "query", _boom) assert scope.excluded_entity_ids(db) == set() def test_demo_kpi_not_traversed_active_kpi_still_is(self, db: Session): """同一形态两份 KPI:demo 账套(e6) 跳过、active 账套(e1) 正常判定(正对照)""" _demo_and_active(db) k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6, unit="%", threshold_red=RED_EXPR) k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6) _kpi_value(db, k_real, "2026-09", 14.45, entity_id=1) st = {} n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW) assert n == 1, "只有 active 账套的 KPI 参与判定" assert st["skipped_demo_kpis"] == 1 assert st["demo_kpis"] == ["DEMO_REVENUE/e6"] assert st["excluded_demo_entities"] == [6] assert [p["kpi"].split("/")[0] for p in st["planned"]] == ["F_REVENUE"] assert kpi_excluded(db, k_demo) is True and kpi_excluded(db, k_real) is False def test_demo_kpi_writes_no_alert(self, db: Session): """非 dry-run:demo KPI 不落库;active KPI 落库(前后计数差 =1)""" _demo_and_active(db) k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6, unit="%", threshold_red=RED_EXPR) k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6) _kpi_value(db, k_real, "2026-09", 14.45, entity_id=1) before = db.query(KPIAlert).count() st = {} n = run_alert_check(db, dry_run=False, stats=st, now=NOW) db.expire_all() rows = db.query(KPIAlert).all() assert n == 1 and len(rows) == before + 1 assert [r.entity_id for r in rows] == [1], "demo 账套零预警" def test_rule_engine_skips_demo_entity(self, db: Session, monkeypatch): """规则引擎(按 AlertRule.entity_id 遍历)跳过 demo 账套""" from app.api import alert_rules as ar from scripts import alert_generator as ag ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True) _demo_and_active(db) called = [] monkeypatch.setattr(ar, "run_forecast_deviation_check", lambda db, entity_id: called.append(("fd", entity_id)) or {"generated": 0}) monkeypatch.setattr(ar, "run_customer_concentration_check", lambda db, entity_id: called.append(("cc", entity_id)) or {"generated": 0}) for eid in (1, 6): db.add(ar.AlertRule(entity_id=eid, kpi_id=1, rule_type="forecast_deviation", enabled=1)) db.commit() stats = ag.run_rule_engine_checks(db) assert stats["excluded_entities"] == [6] assert stats["skipped_demo_entities"] == 1 assert sorted(e for _, e in called) == [1, 1], "demo 账套一条规则都不跑" def test_alert_rules_endpoints_skip_demo(self, db: Session): """生成端点对 demo 账套直接返回 skipped(不起规则遍历)""" from app.api.alert_rules import ( AlertRule, run_all_alert_checks, run_forecast_alert_check, run_forecast_deviation_check, run_customer_concentration_check, ) AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True) _demo_and_active(db) for fn in (run_all_alert_checks, run_forecast_alert_check, run_forecast_deviation_check, run_customer_concentration_check): r = fn(db=db, entity_id=6) assert r.get("skipped") is True, fn.__name__ assert r.get("reason") == "R1_demo_entity_excluded" assert r.get("generated") == 0 def test_cash_alerts_skip_demo(self, db: Session): from app.utils.cash_forecast_engine import check_cash_alerts _demo_and_active(db) r = check_cash_alerts(db, entity_id=6) assert r["skipped"] is True and r["reason"] == "R1_demo_entity_excluded" assert db.query(KPIAlert).count() == 0 def test_deviation_check_excludes_demo_kpi(self, db: Session, monkeypatch): """差异预警遍历(daily_sync 第4步)同样排除 demo 账套 KPI""" import app.utils.deviation_engine as de _demo_and_active(db) create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6, unit="%", threshold_red=RED_EXPR) create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) # 让所有 KPI 都「差异触发」,只观察遍历范围(不依赖预算数据形态) monkeypatch.setattr(de, "build_deviation_alert", lambda db_s, kpi, period, *a, **kw: { "triggered": True, "kpi_alert_level": "red", "alert_type": "budget", "suggestion": None, "deviation": {"actual_value": 14.45, "budget_value": 100.0, "deviation_rate": -85.6}}) monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False}) n = de.run_deviation_check(db, period="2026-08") db.expire_all() rows = db.query(KPIAlert).all() assert n == 1 and [r.entity_id for r in rows] == [1] # ───────────────────────── R2:期间窗口(6 个自然月) ───────────────────────── class TestR2Window: def test_window_lower_period_math(self): assert ALERT_WINDOW_MONTHS == 6 assert window_lower_period(NOW) == "2026-04" # 2026-09 往前 5 个月 assert window_lower_period(datetime(2026, 9, 1)) == "2026-04" assert window_lower_period(datetime(2026, 6, 30)) == "2026-01" assert window_lower_period(datetime(2026, 1, 5)) == "2025-08" # 跨年 assert window_lower_period(datetime(2026, 3, 31)) == "2025-10" def test_period_in_window(self): assert period_in_window("2026-04", NOW) is True # 窗口下限(含) assert period_in_window("2026-09", NOW) is True # 当月(含) assert period_in_window("2026-03", NOW) is False # 窗口外 assert period_in_window("2026-01", NOW) is False assert period_in_window("2026-H1", NOW) is False # R3 assert period_in_window(None, NOW) is False def test_out_of_window_kpi_not_generated(self, db: Session): """博海形态:最新可用期间 2026-01/03(窗口外)→ 不再新生成""" _demo_and_active(db) for code, period in (("BH_SALES_1", "2026-03"), ("BH_SALES_5", "2026-01")): k = create_test_kpi(db, kpi_code=code, kpi_name=code, entity_id=2, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k, period, 63070.3, entity_id=2) st = {} n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW) assert n == 0 assert st["out_of_window"] == 2 assert sorted(st["out_of_window_kpis"]) == ["BH_SALES_1[2026-03]", "BH_SALES_5[2026-01]"] assert st["window_lower_period"] == "2026-04" def test_window_boundary_generates_inside_only(self, db: Session): """边界:窗口下限 2026-04 生成;2026-03 不生成""" _demo_and_active(db) k_in = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1, unit="%", threshold_red=RED_EXPR) k_out = create_test_kpi(db, kpi_code="F_COST_RATIO", kpi_name="费用率", entity_id=1, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k_in, "2026-04", 14.45) _kpi_value(db, k_out, "2026-03", 14.45) st = {} n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW) assert n == 1 assert st["periods_used"] == {"2026-04": 1} assert st["out_of_window"] == 1 assert st["planned"][0]["kpi"].startswith("F_REVENUE") def test_no_new_alert_written_out_of_window(self, db: Session): """非 dry-run 回查:窗口外 KPI 不落库(前后计数一致)""" _demo_and_active(db) k = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k, "2026-01", 63070.3, entity_id=2) before = db.query(KPIAlert).count() run_alert_check(db, dry_run=False, now=NOW) db.expire_all() assert db.query(KPIAlert).count() == before == 0 # ───────────────────────── R3:伪期间不参与 ───────────────────────── class TestR3PseudoPeriod: def test_is_month_period(self): assert is_month_period("2026-08") is True and is_month_period("2025-12") is True for bad in ("2026-H1", "2026-H2", "2025-FY", "2025", "2026-13", None, ""): assert is_month_period(bad) is False, bad def test_explicit_pseudo_period_skipped(self, db: Session): """显式传入 2026-H1 → 伪期间跳过(不放行、不落库)""" _demo_and_active(db) k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率", entity_id=1, unit="%", threshold_red="<15") _kpi_value(db, k, "2026-08", 5.0) before = db.query(KPIAlert).count() st = {} n = run_alert_check(db, period="2026-H1", dry_run=False, stats=st, now=NOW) db.expire_all() assert n == 0 and db.query(KPIAlert).count() == before assert st["skipped_pseudo_period"] == 1 assert st["pseudo_period_kpis"] == ["F_GROSS_MARGIN[2026-H1]"] def test_only_pseudo_period_means_no_period(self, db: Session): """KPI 只有 2026-H1 数据 → 无可用期间(no_period,不静默)""" _demo_and_active(db) k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率", entity_id=1, unit="%", threshold_red="<15") _kpi_value(db, k, "2026-H1", 5.0) st = {} n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW) assert n == 0 and st["no_period"] == 1 and st["skipped_pseudo_period"] == 0 # ───────────────────────── R4:生成与推送解耦 ───────────────────────── class TestR4PushDecoupled: def _triggering_kpi(self, db): _demo_and_active(db) 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", 14.45) return k def test_empty_channels_only_store_no_error(self, db: Session): """notification_channels=0 → 仅入库、不报错、不阻断(生成结果照常返回)""" self._triggering_kpi(db) assert db.query(NotificationChannel).count() == 0 r = generate_and_push(db, dry_run=False, now=NOW) db.expire_all() assert r["new_alerts"] == 1, "预警照常生成入库" assert r["channels_enabled"] == 0 and r["push_skipped_no_channel"] is True assert r["pushed"] == 0 assert db.query(KPIAlert).count() == 1 assert db.query(KPIAlert).first().status == "pending" def test_push_failure_does_not_block_generation(self, db: Session, monkeypatch): """有渠道但推送抛异常 → 生成结果不受影响(try/except 解耦)""" import app.utils.notifier as notifier self._triggering_kpi(db) db.add(NotificationChannel(name="企微群", channel_type="wecom", config={"webhook_url": "https://example.invalid/hook"}, enabled=True)) db.commit() def _boom(*a, **kw): raise RuntimeError("simulated push failure") monkeypatch.setattr(notifier, "push_pending_alerts", _boom) r = generate_and_push(db, dry_run=False, now=NOW) assert r["new_alerts"] == 1 and r["pushed"] == 0 assert r["channels_enabled"] == 1 and r["push_skipped_no_channel"] is False def test_push_called_when_channel_present(self, db: Session, monkeypatch): import app.utils.notifier as notifier self._triggering_kpi(db) db.add(NotificationChannel(name="企微群", channel_type="wecom", config={"webhook_url": "https://example.invalid/hook"}, enabled=True)) db.commit() monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: 1) r = generate_and_push(db, dry_run=False, now=NOW) assert r["new_alerts"] == 1 and r["pushed"] == 1 and r["channels_enabled"] == 1 def test_dry_run_zero_write_and_zero_push(self, db: Session, monkeypatch): """dry-run:零写入、零推送,且返回 R1/R2/R3 口径证据""" import app.utils.notifier as notifier self._triggering_kpi(db) k_old = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2, unit="%", threshold_red=RED_EXPR) _kpi_value(db, k_old, "2026-01", 63070.3, entity_id=2) create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6, unit="%", threshold_red=RED_EXPR) called = [] monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: called.append(1) or 9) before = db.query(KPIAlert).count() r = generate_and_push(db, dry_run=True, now=NOW) db.expire_all() assert db.query(KPIAlert).count() == before == 0, "dry-run 零写入" assert called == [], "dry-run 零推送" assert r["new_alerts"] == 1 and r["pushed"] == 0 assert r["skipped_demo_kpis"] == 1 and r["out_of_window"] == 1 assert r["window_lower_period"] == "2026-04" and r["excluded_demo_entities"] == [6] assert r["push_skipped_no_channel"] is True