Files
cma-management/backend/app/utils/alert_entity_scope.py
T
Hermes CI Fix 3beec14d96 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
2026-09-19 10:05:11 +08:00

97 lines
4.1 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 -*-
"""预警「参与范围」口径 —— 单一事实源(财务裁定 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,
}