Files
cma-management/backend/app/utils/alert_entity_scope.py
T
Hermes CI Fix 242f3cf5ec fix(fetch-caliber): 取数层排除半年/预测伪期间 + subjects 响应补 entity_id
R3 期间口径(仅 YYYY-MM)延伸到取数层:新增 alert_entity_scope.month_period_clause()(跨库可移植 SQL 等价式),应用到 15 个文件 26 处「按 period 倒序取最新值/最近N期」位点;dashboard 原 MySQL-only REGEXP 正则收口到同单一事实源。

GET /api/cma/subjects 响应增补 entity_id(NULL=全局标准准则科目 / N=账套私有明细),既有 10 字段语义与名称不变。

证据:pending-tasks/_reports/yanxue-20260924-cma-fetch-caliber-fixes-fullstack.md(改前/改后 A/B:F_NET_PROFIT 2026-H2/-55.0 → 2026-08/-10.46)。新增 7 例回归测试;全量 1 failed(预存在) 1473 passed,基线同项。
2026-09-26 02:42:04 +08:00

195 lines
9.2 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 month_period_clause(column):
"""R3 的 SQL 等价式:`column` 必须落在规整月 `YYYY-MM`(供**取数层**查询使用)。
为什么取数层必须是 SQL 侧(而不是取完再在 Python 里挑):
取数层的动作是「`ORDER BY period DESC` 取最新一行」(`.first()` / `.limit(N)`)。
伪期间 `2026-H2` 的**字符串序大于任何同年月份**('H' > '0'),所以只要它留在排序里,
「最新实际期」就被它顶掉——取到的是**预测值/半年值**,而且 Python 侧再过滤也救不回来
(最新那一行已经不是月期间,第二行才是),`.first()` 的结果直接变成错值或空。
故本口径必须在 **SQL 侧**参与排序前的筛选。
跨库可移植:只用 `LENGTH` / `SUBSTR` / `BETWEEN` / `IN`(MySQL、SQLite、PostgreSQL 同名可用),
**不用 `REGEXP`**——SQLite 测试库未注册 REGEXP,SQL 侧正则会让单测与生产走两套语义。
与 Python 侧 `is_month_period` 同源等价:长度 7 + 第 5 位 `-` + 月份两位数字且 01–12
(`2026-13` 之类非法月同样排除)。禁止在调用点另写一份长度/正则判断。
"""
from sqlalchemy import and_, func, or_ # 局部导入:本模块顶层不依赖 sqlalchemy 表达式 API
tens = func.substr(column, 6, 1)
ones = func.substr(column, 7, 1)
return and_(
func.length(column) == 7,
func.substr(column, 5, 1) == "-",
or_(
and_(tens == "0", ones.between("1", "9")),
and_(tens == "1", ones.in_(["0", "1", "2"])),
),
)
# ── 裁定 2b(2026-09-17):「各 KPI 自己的最近可用期间」,单一实现 ──
#
# 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`,
# 而 `app/utils/deviation_engine.run_deviation_check` 仍硬编码 `datetime.now()`(当月)⇒
# 期间口径在**两个生成入口之间漂移**,差异/趋势预警对「当月无 kpi_values」的账套永久 0 新增。
# 现收口到本模块(预警口径唯一事实源):两处入口共同调用,禁止再各写一份。
def resolve_latest_available_period(db_session, kpi_id=None) -> Optional[str]:
"""裁定 2b:返回(该 KPI 的)「最近可用期间」——`kpi_values` 内最新的规整月 `YYYY-MM`。
给定 kpi_id → 返回**该 KPI 自己**的最近可用期间:不硬编码当月,也不被别人
(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09 但那是
演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行),
不依赖 MySQL 的 `REGEXP`——SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让单测
与生产走两套语义。判据用 R3 单一正则(`is_month_period`,严格 01–12)。
"""
from app.models import KPIValue # 局部导入:避免 models ↔ utils 的循环导入风险
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None))
if kpi_id is not None:
q = q.filter(KPIValue.kpi_id == kpi_id)
for (p,) in q.distinct().order_by(KPIValue.period.desc()).all():
if is_month_period(p):
return p
return None
# ── 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,
}