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:
@@ -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