Files
cma-management/backend/app/utils/alert_entity_scope.py
T
Hermes CI Fix 5a99ed87e0 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 任总).
2026-09-19 10:42:09 +08:00

136 lines
5.8 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)
# ── 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:
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
return {
"excluded_entities": sorted(excluded_entity_ids(db_session)),
"window_lower_period": window_lower_period(now),
"window_months": ALERT_WINDOW_MONTHS,
}