Files
cma-management/backend/tests/test_alert_caliber_r1r4_20260919.py
T
Hermes CI Fix 8c5853f64b fix(alerts): enforce caliber ruling R5-supp (period linkage)
deviation_engine: link kpi_value_id on deviation/trend alert rows; skip when period not attributable or kpi_values row missing; dedupe key now (entity_id,kpi_id,kpi_value_id,alert_type)

deviation_push: same-source R1/R5 guards, no-period request writes nothing

tests: add test_alert_caliber_r5_residual_20260919 (13 cases incl. negative controls); adapt r1r4/r5 fixtures to the supplemental ruling
2026-09-19 11:12:31 +08:00

367 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)
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)
# R5-补1(2026-09-19):差异预警须落 kpi_value_id 关联,故夹具补该期间的 kpi_values 行
#(否则按补1 跳过不入库,本用例将观察不到「遍历范围」这一被测口径)
_kpi_value(db, k_real, "2026-08", 14.45, entity_id=1)
# 让所有 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