Files
cma-management/backend/tests/test_alert_caliber_r5_residual_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

318 lines
17 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 -*-
"""预警「期间可归期」残口收敛测试(2026-09-19,任务 fullstack-20260919-alert-period-linkage-r5-residual)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`
- **§R5**(2026-09-19):预警生效判据 = 期间为规整月 `YYYY-MM`;不可归期者不参与预警。
- **§补充裁定 R5-补1**(同日,收口残口一):差异/趋势/差异反打路径**必须落 `kpi_value_id`**
(指向同账套 + 同 KPI + 文案所示规整月期间的 `kpi_values` 行);文案期间不可解析为 `YYYY-MM`
或该 (kpi_id, period) 无 `kpi_values` 行 → 跳过不入库;趋势类以**趋势末端当期**为关联期间;
差异类去重键改为 `(entity_id, kpi_id, kpi_value_id, alert_type)`;
`deviation_push` 同源接入 `guard_alert_period` + `entity_alert_guard`,请求体无 `period` 一律不落库。
- **§补充裁定 R5-补2**(同日,收口残口二):类B「无月度期间族」**不屏蔽(现状保留)**,
登记为已知例外,不与类A 混算。
本套件每个「期间关联」用例均带**负对照**:夹具同时给出「旧逻辑会取到的目标(未关联 / 别期行)」
与「新逻辑应取到的目标」,断言新逻辑取新目标、且**不等于**旧目标——否则判据未生效也能通过。
"""
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi, create_test_user
from app.models import (Entity, KPIValue, KPIAlert, KPIDefinition, StrategicMap,
ActionPlan, OperationLog)
NOW = datetime(2026, 9, 19, 10, 0)
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=None):
row = KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value)
db.add(row)
db.commit()
return row
def _trigger_deviation(db, monkeypatch):
"""隔离趋势分支:只观察差异分支的落库形态"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert",
lambda db_s, kpi, period, *a, **kw: {
"triggered": True, "kpi_alert_level": "red", "alert_type": "budget",
"suggestion": "探针建议",
"deviation": {"actual_value": 88.8, "budget_value": 10.0,
"deviation_rate": 788.0}})
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
return de
# ─────────────────── 残口一:差异/趋势路径期间关联(补1) ───────────────────
class TestR5ResidualDeviationLinkage:
def test_diff_alert_links_current_period_row_not_other_period(self, db: Session, monkeypatch):
"""差异预警必须关联「文案所示期间」那一行(负对照:不得关联别期行,也不得留空)
夹具两个 kpi_values 行:2026-06(旧逻辑在别处的可能目标 / 干扰项)与 2026-07(文案期间)。
"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
kv_prev = _kpi_value(db, k, "2026-06", 60.0)
kv_cur = _kpi_value(db, k, "2026-07", 88.8)
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 == kv_cur.id, "新逻辑取新目标(文案期间 2026-07 的 kpi_values 行)"
assert rows[0].kpi_value_id != kv_prev.id, "负对照:不得关联别期行"
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL,期间不可派生)"
# 派生期间可复算 = 可归期
derived = db.query(KPIValue).filter(KPIValue.id == rows[0].kpi_value_id).first()
assert derived.period == "2026-07"
def test_diff_alert_skipped_when_no_kpi_value_row(self, db: Session, monkeypatch):
"""该 (kpi, period) 无 kpi_values 行 → 跳过不入库(不得以 NULL 关联落行)"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
assert n == 0
assert db.query(KPIAlert).count() == 0
def test_trend_alert_links_trend_end_period(self, db: Session, monkeypatch):
"""趋势类以「计算基准期(趋势末端当期)」为关联期间(负对照:不得关联趋势起点期)"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
kv_start = _kpi_value(db, k, "2026-05", 30.0)
_kpi_value(db, k, "2026-06", 20.0)
kv_end = _kpi_value(db, k, "2026-07", 10.0) # 连续 3 期下滑 → 趋势末端
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 "[趋势预警]" in rows[0].alert_message
assert rows[0].kpi_value_id == kv_end.id, "新逻辑:关联趋势末端当期"
assert rows[0].kpi_value_id != kv_start.id, "负对照:不得关联趋势起点期"
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL)"
def test_trend_alert_skipped_when_base_period_not_attributable(self, db: Session, monkeypatch):
"""趋势末端期间不可归期(非 YYYY-MM)→ 跳过不入库(补1:文案期间不可解析即跳过)"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
monkeypatch.setattr(de, "check_trend_anomaly",
lambda *a, **kw: {"anomaly": True, "level": "yellow",
"message": "连续3期下滑", "periods": ["2026-H1"]})
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
assert n == 0 and db.query(KPIAlert).count() == 0
def test_diff_dedupe_key_is_linkage_tuple(self, db: Session, monkeypatch):
"""去重键改为 (entity_id, kpi_id, kpi_value_id, alert_type)
负对照:预置一条「文案含同期间、但 kpi_value_id 为空」的历史行(= 旧去重键会命中的形态),
新逻辑按可归期目标去重 → 仍应补一条带关联的预警(而非被文案去重吃掉)。
"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
_kpi_value(db, k, "2026-07", 88.8)
# 旧形态遗留行:文案带期间、无期间关联
db.add(KPIAlert(entity_id=1, kpi_id=k.id, kpi_value_id=None, alert_level="red",
alert_type="budget", status="pending",
alert_message="[差异预警] 新客户数[2026-07] 差异预警: 实际88.8 vs 预算10.0,差异率788.0%"))
db.commit()
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
assert n == 1 and len(rows) == 2, "新去重键按可归期目标判定 → 补一条带关联的行"
linked = [r for r in rows if r.kpi_value_id is not None]
assert len(linked) == 1 and linked[0].alert_type == "budget"
def test_diff_dedupe_does_not_duplicate_same_target(self, db: Session, monkeypatch):
"""正对照:同一可归期目标重复触发不重复入库(幂等)"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
_kpi_value(db, k, "2026-07", 88.8)
assert de.run_deviation_check(db, period="2026-07") == 1
assert de.run_deviation_check(db, period="2026-07") == 0
assert db.query(KPIAlert).count() == 1
# ─────────────────── 残口三:deviation_push 的 R1/R5 缺口(补1) ───────────────────
class TestR5ResidualDeviationPush:
def _fixture(self, db, entity_id=1, code="TEST_001"):
k = create_test_kpi(db, kpi_code=code, kpi_name="探针KPI", entity_id=entity_id,
unit="%", target_value=100.0)
db.add(StrategicMap(entity_id=entity_id, title="探针地图", status="published",
dimensions=[{"key": "finance", "name": "财务维度",
"objectives": [{"name": "探针目标", "kpis": [code]}]}]))
db.commit()
m = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).first()
return k, m
def _call(self, db, m, data, entity_id=1):
from app.api.deviation_push import push_deviation_to_map
from app.models import User
uname = "r5res%d" % entity_id
user = db.query(User).filter(User.username == uname).first()
if user is None:
user = create_test_user(db, username=uname, role="ceo")
return push_deviation_to_map(data=data, db=db, entity_id=entity_id, current_user=user)
def test_demo_entity_alert_skipped_value_still_written(self, db: Session):
"""R1:demo 会话账套不产生业务预警(KPI 值照写,与 bot_bridge 同口径)"""
_ent(db, 6, "demo", "演示企业(虚构数据)")
k, m = self._fixture(db, entity_id=6, code="DEMO_PUSH")
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False}, entity_id=6)
db.expire_all()
assert r["alert_created"] is False
assert r["alert_skipped_reason"] == "R1_demo_entity_excluded"
assert db.query(KPIAlert).count() == 0
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值照写"
def test_pseudo_period_alert_skipped(self, db: Session):
"""R5:伪期间请求不产生预警行(返回体登记跳过原因)"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="PSEUDO_PUSH")
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-H1", "createReviewTopic": False})
db.expire_all()
assert r["alert_created"] is False
assert r["alert_skipped_reason"] == "R5_non_attributable_period"
assert db.query(KPIAlert).count() == 0
def test_missing_period_zero_write(self, db: Session):
"""补1:「请求体无 period 一律不落库」——整请求零业务写入"""
_ent(db, 1, "active")
self._fixture(db, code="NOPERIOD_PUSH")
r = self._call(db, {"id": 1}, {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0})
db.expire_all()
assert r["alert_created"] is False and r["zero_write"] is True
assert r["alert_skipped_reason"] == "no_period"
assert db.query(KPIValue).count() == 0, "不得用当月兜底冒充期间写值"
assert db.query(KPIAlert).count() == 0
assert db.query(ActionPlan).count() == 0
assert db.query(OperationLog).count() == 0
def test_valid_request_alert_links_request_period_row(self, db: Session):
"""正对照 + 期间关联:规整月 + active 账套 → 预警落库且关联本次请求期间的行"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="VALID_PUSH")
kv_other = _kpi_value(db, k, "2026-08", 50.0) # 干扰项:别期行
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False})
db.expire_all()
assert r["alert_created"] is True and r["alert_skipped_reason"] is None
alert = db.query(KPIAlert).filter(KPIAlert.id == r["alert_id"]).first()
linked = db.query(KPIValue).filter(KPIValue.id == alert.kpi_value_id).first()
assert linked is not None and linked.period == "2026-09"
assert alert.kpi_value_id != kv_other.id, "负对照:不得关联别期行"
def test_repeat_push_reuses_value_row_linkage(self, db: Session):
"""同一 (kpi, period) 二次反打 → kpi_values 行复用(值就地更新,不新增行)
现状登记:本端点**无预警去重**(第二次反打仍落一条 pending 预警,关联同一 `kpi_values` 行)——
裁定 R5-补1 未规定该端点的去重语义(只规定了差异/趋势类的去重键),故按现状登记,
见报告「观察项」;本用例只锁「期间关联一致 + kpi_values 不重复」这两条已定口径。
"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="REPEAT_PUSH")
d = {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False}
r1 = self._call(db, m, dict(d))
r2 = self._call(db, m, dict(d))
db.expire_all()
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值行复用"
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
assert len(rows) == 2, "现状登记:该端点无预警去重"
assert rows[0].kpi_value_id == rows[1].kpi_value_id, "两次反打关联同一期间行"
assert r1["alert_id"] != r2["alert_id"]
# ─────────────────── 残口二:类B 不屏蔽(补2,现状登记) ───────────────────
class TestR5ResidualClassBStatusQuo:
"""补2 裁定:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外。
本类为**现状登记**(非守卫断言):这些族的驱动量是日期/占比/趋势,其可行动性不以月度期间
为前提;屏蔽会灭掉「到期未收款」等既有能力(存量 423 行族)并触发既有套件失败。
若产品侧日后改判屏蔽,本类应随之更新(与 `test_alert_caliber_r5_20260919.py` 同口径)。
"""
def test_customer_concentration_site_still_writes_without_linkage(self, db: Session, monkeypatch):
"""集中度(类B,alert_rules:940)落库仍**不经** R5 期间守卫 → 行内无 kpi_value 关联
夹具以 monkeypatch 替代占比计算,观察点 = 「落库是否被期间守卫拦截」。
"""
from app.api import alert_rules as ar
ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="CONC_KPI", kpi_name="客户集中度", entity_id=1, unit="%")
db.add(ar.AlertRule(entity_id=1, kpi_id=k.id, rule_type="customer_concentration", enabled=1,
params={"threshold": 30}))
db.commit()
monkeypatch.setattr(ar, "_check_customer_concentration",
lambda *a, **kw: ("red", "[客户集中度] 探针 占比 88%"))
r = ar.run_customer_concentration_check(db=db, entity_id=1)
db.expire_all()
rows = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
assert r["generated"] == 1 and len(rows) == 1, "类B 现状保留:集中度预警照常入库"
assert rows[0].kpi_value_id is None, "类B 行不携带月度期间(登记形态)"
def test_class_b_creation_sites_have_no_r5_period_guard(self):
"""登记:类B 其余创建点(集中度/预测值/资金缺口/到期未收款)**未接入** R5 期间守卫
判据 = 源码级登记(这些文件在本单中零改动),与 `git diff --stat` 互为佐证。
"""
import inspect
from app.api import alert_rules as ar
from app.utils import cash_forecast_engine as cfe
for fn in (ar.run_customer_concentration_check,
ar.run_forecast_alert_check,
cfe.check_cash_alerts):
src = inspect.getsource(fn)
assert "guard_alert_period" not in src, fn.__name__