fix(alerts): enforce caliber rulings R1-R4 for alert generation

R1: exclude status=demo entities from alert traversal (alert_generator / deviation_engine / kpi_bep_deriver / alert_rules / cash engine)

R2: only generate alerts within the last 6 calendar months (incl. current)

R3: only regular month periods (YYYY-MM) participate

R4: decouple generation from push - empty notification_channels never blocks

Single source of truth: app/utils/alert_entity_scope.py
This commit is contained in:
Hermes CI Fix
2026-09-19 10:05:11 +08:00
parent d785a22efd
commit 3beec14d96
7 changed files with 641 additions and 21 deletions
+34 -4
View File
@@ -14,6 +14,8 @@ from app.database import get_db, Base
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
from app.utils.alert_entity_scope import is_excluded_entity
logger = logging.getLogger("alert_rules")
@@ -257,7 +259,14 @@ def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depen
@router.post("/check-all")
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成 → 直接返回 skipped。
"""
if is_excluded_entity(db, entity_id):
logger.info("预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成", "generated": 0,
"skipped": True, "reason": "R1_demo_entity_excluded"}
rules = db.query(AlertRule).filter(
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
kpi_cache = {}
@@ -642,7 +651,14 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
@router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测值预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
generated = _check_forecast_alerts(db, entity_id=entity_id)
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
@@ -690,7 +706,14 @@ def run_forecast_deviation_check(
):
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
偏差 > threshold_pct → 生成/更新 pending 预警(去重)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测偏差检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
from app.models import KpiForecastLog
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
@@ -861,7 +884,14 @@ def run_customer_concentration_check(
entity_id: int = Depends(get_entity_id),
):
"""客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则,
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。"""
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("客户集中度检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == "customer_concentration",
+96
View File
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""预警「参与范围」口径 —— 单一事实源(财务裁定 R1/R2/R3,2026-09-19)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
- **R1 账套范围**:`entities.status = 'demo'` 的账套(演示账套)**不参与预警生成/遍历**
—— demo 仅用于演示展示,不产生业务预警。
- **R2 期间窗口**:预警遍历期间 = **当月往前 6 个自然月(含当月)**;更早期间**不再新生成**。
与裁定 2b(各 KPI 最近可用期间)叠加:`max(可用期间下限, 当月-6月)`。
- **R3 规整月期间**:仅 `YYYY-MM` 参与;`H1/H2/FY`、纯年份等伪期间**一律不参与**。
约定:所有预警生成入口(alert_generator / deviation_engine / kpi_bep_deriver /
alert_rules 生成端点 / cash_forecast_engine)**只调本模块的入口函数**,
禁止在调用点各写一份 `status == 'demo'` / 期间正则 / 窗口算术(避免多处漂移)。
"""
import logging
import re
from datetime import datetime
from typing import Optional
from app.models import Entity, KPIDefinition
logger = logging.getLogger("cma.alert_scope")
#: R1:不参与预警的账套状态
ALERT_EXCLUDED_ENTITY_STATUSES = ("demo",)
#: R2:期间窗口长度(含当月,6 = 当月 + 往前 5 个月)
ALERT_WINDOW_MONTHS = 6
#: R3:规整月期间(严格校验 01–12,`2026-13` 之类非法月同样不参与)
MONTH_PERIOD_RE = r"^[0-9]{4}-(?:0[1-9]|1[0-2])$"
_MONTH_PERIOD_RE = re.compile(MONTH_PERIOD_RE)
def excluded_entity_ids(db_session) -> set:
"""R1:返回不参与预警遍历的账套 id 集合(当前 status='demo' → entity 3/4/5/6)。
fail-open:读不到 entities 表(测试库/权限异常)时返回空集并记 WARNING——
口径缺失不应把预警主流程整条打断(生成链路另有 fail-safe)。
"""
try:
rows = db_session.query(Entity.id).filter(
Entity.status.in_(ALERT_EXCLUDED_ENTITY_STATUSES)
).all()
return {int(r[0]) for r in rows}
except Exception as e: # pragma: no cover - 防御分支
logger.warning("读取 entities.status=%s 失败,本次按「不排除任何账套」处理: %s",
list(ALERT_EXCLUDED_ENTITY_STATUSES), e)
return set()
def is_excluded_entity(db_session, entity_id: Optional[int]) -> bool:
"""R1:单个账套是否被排除(None → 按默认账套 1 处理,与 KPIAlert.entity_id 缺省一致)"""
return int(entity_id or 1) in excluded_entity_ids(db_session)
def kpi_excluded(db_session, kpi: KPIDefinition, excluded: Optional[set] = None) -> bool:
"""R1:KPI 归属账套是否被排除(可传入预取的 excluded 集合,避免循环内重复查询)"""
eids = excluded if excluded is not None else excluded_entity_ids(db_session)
return int(getattr(kpi, "entity_id", None) or 1) in eids
def window_lower_period(now: Optional[datetime] = None) -> str:
"""R2:期间窗口下限 = 当月往前 6 个自然月(含当月)→ 当月-5 月。
2026-09 → `2026-04`(窗口 = 2026-04 ~ 2026-09)。
"""
now = now or datetime.now()
months = now.month - (ALERT_WINDOW_MONTHS - 1)
year = now.year
while months <= 0:
months += 12
year -= 1
return "%04d-%02d" % (year, months)
def is_month_period(period) -> bool:
"""R3:是否规整月期间(YYYY-MM)"""
return bool(_MONTH_PERIOD_RE.fullmatch(str(period or "")))
def period_in_window(period, now: Optional[datetime] = None) -> bool:
"""R3 + R2:期间是否可参与预警生成(规整月 + 落在窗口内)"""
if not is_month_period(period):
return False
return str(period) >= window_lower_period(now)
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
return {
"excluded_entities": sorted(excluded_entity_ids(db_session)),
"window_lower_period": window_lower_period(now),
"window_months": ALERT_WINDOW_MONTHS,
}
@@ -806,9 +806,17 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
"""
import json as _json
from app.models import KPIAlert, CashPlan, KPIDefinition
from app.utils.alert_entity_scope import is_excluded_entity
if is_excluded_entity(db, entity_id):
logger.info("资金预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"entity_id": entity_id, "skipped": True, "reason": "R1_demo_entity_excluded",
"new_alerts": [], "refreshed": []}
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
new_alerts = []
+22 -1
View File
@@ -14,6 +14,10 @@ from typing import Optional
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
from app.utils.alert_entity_scope import (
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
)
logger = logging.getLogger("cma.deviation")
@@ -311,16 +315,31 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope`):
· R1 —— `status='demo'` 的账套不参与遍历(demo 仅演示,不产生业务预警);
· R2/R3 —— 期间须为规整月 `YYYY-MM` 且落在窗口(当月往前 6 个自然月,含当月)内。
"""
if period is None:
period = datetime.now().strftime("%Y-%m")
if not period_in_window(period):
logger.warning(" 差异预警跳过(R2/R3): 期间 %s 非规整月或早于窗口下限 %s",
period, window_lower_period())
return 0
excluded = excluded_entity_ids(db_session)
kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active"
).all()
new_count = 0
skipped_demo = 0
for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1
continue
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
@@ -375,6 +394,8 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
if skipped_demo:
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", skipped_demo)
db_session.commit()
return new_count