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
367 lines
18 KiB
Python
367 lines
18 KiB
Python
# -*- 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
|