Files
cma-management/backend/tests/test_alert_caliber_r5_20260919.py
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

240 lines
12 KiB
Python
Raw Permalink 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`)。
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 例)。