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 任总).
This commit is contained in:
@@ -15,7 +15,8 @@ from app.deps import get_entity_id
|
|||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||||
from app.utils.alert_entity_scope import is_excluded_entity
|
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||||
|
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
||||||
|
|
||||||
logger = logging.getLogger("alert_rules")
|
logger = logging.getLogger("alert_rules")
|
||||||
|
|
||||||
@@ -273,6 +274,9 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
value_cache = {}
|
value_cache = {}
|
||||||
|
|
||||||
alerts_generated = 0
|
alerts_generated = 0
|
||||||
|
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
|
||||||
|
skipped_non_month_period = 0
|
||||||
|
skipped_non_month_period_kpis = []
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
try:
|
try:
|
||||||
@@ -341,6 +345,14 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
||||||
|
|
||||||
if alert_level and alert_level != "green":
|
if alert_level and alert_level != "green":
|
||||||
|
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库。
|
||||||
|
# 注:`latest_value` 按 period 字符串倒序取,`2026-H1` 会排在 `2026-09` 之前 →
|
||||||
|
# 伪期间确实可能被取为「最新值」,此处是伪期间入库唯一的拦截点。
|
||||||
|
if not guard_alert_period(period, source="alert_rules.run_all_alert_checks",
|
||||||
|
detail="kpi=%s rule_id=%s" % (kpi.kpi_code, rule.id)):
|
||||||
|
skipped_non_month_period += 1
|
||||||
|
skipped_non_month_period_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
||||||
|
continue
|
||||||
# 检查是否已有相同预警
|
# 检查是否已有相同预警
|
||||||
existing_alert = db.query(KPIAlert).filter(
|
existing_alert = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == rule.kpi_id,
|
KPIAlert.kpi_id == rule.kpi_id,
|
||||||
@@ -366,7 +378,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
|
if skipped_non_month_period:
|
||||||
|
logger.info("R5 期间守卫:本次跳过 %s 条不可归期预警(%s)",
|
||||||
|
skipped_non_month_period, skipped_non_month_period_kpis)
|
||||||
|
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated,
|
||||||
|
"skipped_non_month_period": skipped_non_month_period,
|
||||||
|
"skipped_non_month_period_kpis": skipped_non_month_period_kpis}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dynamic-thresholds")
|
@router.get("/dynamic-thresholds")
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from app.models.budget_plan import BudgetPlan
|
|||||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||||
from app.models import KPICausality
|
from app.models import KPICausality
|
||||||
from app.risk_levels import risk_level
|
from app.risk_levels import risk_level
|
||||||
|
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||||
|
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||||
import json
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger("cma.bot_bridge")
|
logger = logging.getLogger("cma.bot_bridge")
|
||||||
@@ -673,6 +675,10 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
period = period or datetime.now().strftime("%Y-%m")
|
period = period or datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
|
# R1(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警
|
||||||
|
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge.kpi-value-with-check",
|
||||||
|
detail="kpi_id=%s" % kpi_id)
|
||||||
|
|
||||||
# ① 写值(upsert)
|
# ① 写值(upsert)
|
||||||
existing = db.query(KPIValue).filter(
|
existing = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
|
||||||
@@ -689,7 +695,9 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
|
|
||||||
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
|
||||||
alerts = []
|
alerts = []
|
||||||
if run_check:
|
# R5(裁定 2026-09-19):期间非规整月(含伪期间)→ 不入库,跳过并记数
|
||||||
|
skipped_non_month_period = 0
|
||||||
|
if run_check and alerts_allowed:
|
||||||
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
|
||||||
rules = db.query(AlertRule).filter(
|
rules = db.query(AlertRule).filter(
|
||||||
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
|
||||||
@@ -709,6 +717,11 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if level and level != "green":
|
if level and level != "green":
|
||||||
|
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 → 该 kpi_alerts 行不入库
|
||||||
|
if not guard_alert_period(period, source="bot_bridge.kpi-value-with-check",
|
||||||
|
detail="kpi_id=%s rule_id=%s" % (kpi_id, rule.id)):
|
||||||
|
skipped_non_month_period += 1
|
||||||
|
continue
|
||||||
dup = db.query(KPIAlert).filter(
|
dup = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
|
||||||
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
|
||||||
@@ -720,8 +733,13 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
|
||||||
"period": period, "alerts": alerts, "status": "ok"}
|
"period": period, "alerts": alerts, "status": "ok",
|
||||||
|
"skipped_non_month_period": skipped_non_month_period}
|
||||||
|
if not alerts_allowed:
|
||||||
|
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警(值照写、预警跳过)
|
||||||
|
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/kpis/create-with-links")
|
@router.post("/kpis/create-with-links")
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from app.models import (
|
|||||||
ActionPlan, Entity,
|
ActionPlan, Entity,
|
||||||
)
|
)
|
||||||
from app.risk_levels import risk_level
|
from app.risk_levels import risk_level
|
||||||
|
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||||
|
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||||
|
|
||||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||||
|
|
||||||
@@ -307,6 +309,12 @@ def receive_mpm_result(
|
|||||||
if not entity:
|
if not entity:
|
||||||
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
|
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
|
||||||
|
|
||||||
|
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
|
||||||
|
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
|
||||||
|
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
|
||||||
|
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
|
||||||
|
detail="entity_id=%s" % entity_id)
|
||||||
|
|
||||||
# ── 1. 写入MPM结果 ──
|
# ── 1. 写入MPM结果 ──
|
||||||
record = MpmResult(
|
record = MpmResult(
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
@@ -367,6 +375,13 @@ def receive_mpm_result(
|
|||||||
|
|
||||||
if (operator == ">" and field_value > threshold) or \
|
if (operator == ">" and field_value > threshold) or \
|
||||||
(operator == ">=" and field_value >= threshold):
|
(operator == ">=" and field_value >= threshold):
|
||||||
|
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
|
||||||
|
if not alerts_allowed:
|
||||||
|
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
|
||||||
|
continue
|
||||||
|
if not period_attributable:
|
||||||
|
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
|
||||||
|
continue
|
||||||
# 查找关联KPI
|
# 查找关联KPI
|
||||||
kpi_id = None
|
kpi_id = None
|
||||||
if kpi_code:
|
if kpi_code:
|
||||||
@@ -394,7 +409,7 @@ def receive_mpm_result(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
db.refresh(record)
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"kpi_updated": len(kpi_updates),
|
"kpi_updated": len(kpi_updates),
|
||||||
"alerts_triggered": len(alerts),
|
"alerts_triggered": len(alerts),
|
||||||
@@ -403,7 +418,12 @@ def receive_mpm_result(
|
|||||||
"kpi_codes": kpi_updates,
|
"kpi_codes": kpi_updates,
|
||||||
"alerts": alerts,
|
"alerts": alerts,
|
||||||
},
|
},
|
||||||
|
"skipped_non_month_period": (not period_attributable),
|
||||||
}
|
}
|
||||||
|
if not alerts_allowed:
|
||||||
|
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
|
||||||
|
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/kpi-result")
|
@router.post("/kpi-result")
|
||||||
|
|||||||
@@ -87,6 +87,45 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
|
|||||||
return str(period) >= window_lower_period(now)
|
return str(period) >= window_lower_period(now)
|
||||||
|
|
||||||
|
|
||||||
|
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
|
||||||
|
|
||||||
|
def is_attributable_period(period) -> bool:
|
||||||
|
"""R5:期间是否「可归期」= 规整月 `YYYY-MM`。
|
||||||
|
|
||||||
|
`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警
|
||||||
|
(不可复核、不可归责、不可驱动行动)。
|
||||||
|
"""
|
||||||
|
return is_month_period(period)
|
||||||
|
|
||||||
|
|
||||||
|
def guard_alert_period(period, *, source: str = "", detail: str = "") -> bool:
|
||||||
|
"""R5 生成侧守卫:期间非规整月(含 None)→ 该 `kpi_alerts` 行**不入库**(跳过 + 记日志)。
|
||||||
|
|
||||||
|
返回 True = 放行(可入库);False = 跳过(**调用点必须不落库**)。
|
||||||
|
裁定原文:「任何生成路径产出的 kpi_alerts 行,period 非 YYYY-MM 一律不入库(跳过并记日志),防复发。」
|
||||||
|
"""
|
||||||
|
if is_attributable_period(period):
|
||||||
|
return True
|
||||||
|
logger.info(
|
||||||
|
"R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r source=%s detail=%s",
|
||||||
|
period, source, detail)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def entity_alert_guard(db_session, entity_id, *, source: str = "", detail: str = "") -> bool:
|
||||||
|
"""R1 通用生成侧守卫:demo 账套在任何写通道上都不得产生业务预警。
|
||||||
|
|
||||||
|
「R1 与入口无关」——包含按显式 `entity_id` 写入的 Bot 通道(bot_bridge / bot_bridge_v2 等)。
|
||||||
|
返回 True = 放行;False = 跳过(原因 = R1 demo 账套排除,沿用 `excluded_entity_ids`)。
|
||||||
|
"""
|
||||||
|
if is_excluded_entity(db_session, entity_id):
|
||||||
|
logger.info(
|
||||||
|
"R1 账套守卫:跳过不入库(demo 演示账套不产生业务预警)entity_id=%s source=%s detail=%s",
|
||||||
|
entity_id, source, detail)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
|
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
|
||||||
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
|
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# -*- 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:待产品侧决定是否补关联)"
|
||||||
Reference in New Issue
Block a user