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:
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user