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,基线同项。
195 lines
9.2 KiB
Python
195 lines
9.2 KiB
Python
# -*- 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,
|
||
}
|