Files
cma-management/backend/tests/test_alert_caliber_r5_20260919.py
T
Hermes CI Fix 5a99ed87e0 fix(alerts): enforce caliber ruling R5
- alert_entity_scope: add is_attributable_period / guard_alert_period (R5) and entity_alert_guard (R1, any write path)
- alert_rules.run_all_alert_checks: skip non-YYYY-MM period alert rows (R5)
- bot_bridge / bot_bridge_v2: R1 demo-entity guard + R5 period guard on write paths
- tests: new R5 suite (10 cases)

Not pushed / not deployed (release gate held by 任总).
2026-09-19 10:42:09 +08:00

248 lines
12 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 -*-
"""财务预警口径裁定 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`);
裁定边界原文「若日后确有『无期间合法预警』的业务需求,由产品侧另提,本裁定不预设」
→ 本单登记为待产品侧决策项,不做静默屏蔽(会灭掉既有族并触发既有套件失败)。
"""
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 实测证据:这些生成路径产出的行**期间无从从库派生**。
裁定边界原文:「若日后确有『无期间合法预警』的业务需求,由产品侧另提,本裁定不预设」。
本单结论:按边界**不做静默屏蔽**(屏蔽会灭掉 423 行既有族 + 触发既有套件失败),
登记为待产品侧决策项。若产品侧裁定需屏蔽,本用例应随之更新。
"""
def test_deviation_path_row_has_no_derivable_period(self, db: Session, monkeypatch):
"""差异预警路径(cron daily_sync 第 4 步):期间为规整月,但行内无 kpi_value 关联
⇒ 库内派生期间 = NULL(id 17 同类形态,可复现)"""
import app.utils.deviation_engine as de
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
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": 88.8, "budget_value": 10.0,
"deviation_rate": 788.0}})
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
rows = db.query(KPIAlert).all()
assert n == 1 and len(rows) == 1
assert rows[0].kpi_value_id is None, "差异预警不落 kpi_value 关联"
assert "[差异预警]" in rows[0].alert_message and "2026-07" in rows[0].alert_message, \
"期间存在于文案中,但不可由库派生(类B:待产品侧决定是否补关联)"