fix(alerts): enforce caliber ruling R5-supp (period linkage)
deviation_engine: link kpi_value_id on deviation/trend alert rows; skip when period not attributable or kpi_values row missing; dedupe key now (entity_id,kpi_id,kpi_value_id,alert_type) deviation_push: same-source R1/R5 guards, no-period request writes nothing tests: add test_alert_caliber_r5_residual_20260919 (13 cases incl. negative controls); adapt r1r4/r5 fixtures to the supplemental ruling
This commit is contained in:
@@ -17,6 +17,7 @@ from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConf
|
||||
# 裁定 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,
|
||||
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.deviation")
|
||||
@@ -80,13 +81,46 @@ def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Opt
|
||||
return plan.budget_value if plan else None
|
||||
|
||||
|
||||
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的实际值"""
|
||||
val = db.query(KPIValue).filter(
|
||||
def get_kpi_value_row(db, kpi_id: int, period: str):
|
||||
"""取 (kpi_id, period) 的 KPIValue 行(取行口径唯一来源)。
|
||||
|
||||
与 get_actual_for_kpi 同源:预警行关联的 kpi_values 行 = 数值来源那一行。
|
||||
"""
|
||||
return db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
return val.actual_value if val else None
|
||||
|
||||
|
||||
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的实际值"""
|
||||
row = get_kpi_value_row(db, kpi_id, period)
|
||||
return row.actual_value if row else None
|
||||
|
||||
|
||||
def resolve_alert_kpi_value(db, kpi, period: str):
|
||||
"""R5-补1(财务裁定 2026-09-19):解析差异/趋势预警行必须关联的 `kpi_values` 行。
|
||||
|
||||
裁定原文:「此类预警行必须在落库时写入 `kpi_value_id`,指向 **同 `entity_id` +
|
||||
同 `kpi_id` + 文案所示规整月期间** 的 `kpi_values` 行。文案期间不可解析为 `YYYY-MM`,
|
||||
或该 (kpi_id, period) 无 `kpi_values` 行 → 按 R5 `guard_alert_period` 跳过不入库
|
||||
(不得以 `kpi_value_id=NULL` 落行——『文案里有期间』不构成可归期)。」
|
||||
|
||||
- 账套约束由 `kpi_id` 传递:KPIDefinition 归属单一账套,故 kpi_id 已唯一确定 entity_id;
|
||||
取行口径与 `get_kpi_value_row`(= `get_actual_for_kpi` 的来源行)完全一致,
|
||||
保证「预警关联的那一行」就是该预警数值的来源行,不会关联到别的账套/别的期间。
|
||||
- 返回 `(kpi_value_row | None, skip_reason | None)`;跳过原因供调用点计数与日志。
|
||||
"""
|
||||
if not guard_alert_period(period, source="deviation_engine.resolve_alert_kpi_value",
|
||||
detail="kpi=%s" % getattr(kpi, "kpi_code", "")):
|
||||
return None, "R5_non_attributable_period"
|
||||
row = get_kpi_value_row(db, kpi.id, period)
|
||||
if row is None:
|
||||
logger.info(
|
||||
"R5-补1 期间关联:跳过不入库(该 (kpi, period) 无 kpi_values 行,期间不可派生)"
|
||||
" kpi=%s period=%s", getattr(kpi, "kpi_code", ""), period)
|
||||
return None, "no_kpi_value_row"
|
||||
return row, None
|
||||
|
||||
|
||||
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
|
||||
@@ -320,6 +354,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope`):
|
||||
· R1 —— `status='demo'` 的账套不参与遍历(demo 仅演示,不产生业务预警);
|
||||
· R2/R3 —— 期间须为规整月 `YYYY-MM` 且落在窗口(当月往前 6 个自然月,含当月)内。
|
||||
· R5-补1 —— 差异/趋势分支**必须落 `kpi_value_id` 关联**(同 kpi 同期间的 `kpi_values` 行);
|
||||
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
|
||||
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
|
||||
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
|
||||
"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
@@ -335,6 +373,8 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
|
||||
new_count = 0
|
||||
skipped_demo = 0
|
||||
skipped_period_linkage = 0
|
||||
skipped_linkage_kpis = []
|
||||
for kpi in kpis:
|
||||
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
||||
if kpi_excluded(db_session, kpi, excluded):
|
||||
@@ -349,53 +389,76 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
f"vs 预算{deviation['budget_value']},"
|
||||
f"差异率{deviation['deviation_rate']}%"
|
||||
)
|
||||
alert_type = result["alert_type"] or "actual"
|
||||
|
||||
# 检查是否已有同KPI同期间的差异预警
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[差异预警]"),
|
||||
KPIAlert.alert_message.contains(period),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
|
||||
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
|
||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, period)
|
||||
if linked_kv is None:
|
||||
skipped_period_linkage += 1
|
||||
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
||||
else:
|
||||
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
|
||||
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == (kpi.entity_id or 1),
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.kpi_value_id == linked_kv.id,
|
||||
KPIAlert.alert_type == alert_type,
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
|
||||
kpi_id=kpi.id,
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=result["alert_type"] or "actual",
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=linked_kv.id, # R5-补1: 期间经该行可派生(可归期)
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=alert_type,
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
|
||||
# 2. 趋势异常检测(每期检查连续3期)
|
||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
|
||||
trend_alert_msg = trend["message"]
|
||||
existing_trend = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[趋势预警]"),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
|
||||
trend_periods = trend.get("periods") or []
|
||||
base_period = trend_periods[-1] if trend_periods else period
|
||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
|
||||
if linked_kv is None:
|
||||
skipped_period_linkage += 1
|
||||
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, base_period))
|
||||
else:
|
||||
existing_trend = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[趋势预警]"),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing_trend:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
alert_level=trend["level"],
|
||||
alert_message=f"[趋势预警] {trend_alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
|
||||
if not existing_trend:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=linked_kv.id, # R5-补1: 趋势末端当期关联
|
||||
alert_level=trend["level"],
|
||||
alert_message=f"[趋势预警] {trend_alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
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)
|
||||
if skipped_period_linkage:
|
||||
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
|
||||
skipped_period_linkage, skipped_linkage_kpis)
|
||||
db_session.commit()
|
||||
return new_count
|
||||
|
||||
|
||||
Reference in New Issue
Block a user