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:
@@ -8,9 +8,10 @@ from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.deviation_push")
|
||||
|
||||
@@ -49,6 +50,22 @@ def push_deviation_to_map(
|
||||
if new_value is None:
|
||||
raise HTTPException(400, "缺少 newValue")
|
||||
|
||||
# R5-补1(财务裁定 2026-09-19):「请求体无 period 一律不落库」
|
||||
# 旧码在缺 period 时用当月冒充期间再落库(预警 + kpi_values 双写)——
|
||||
# 该兜底正好绕过 R5「可归期」判据,故此处直接整请求零业务写入返回。
|
||||
if not period:
|
||||
logger.info(
|
||||
"R5-补1 期间守卫:deviation_push 请求无 period,整请求零业务写入"
|
||||
" entity_id=%s map_id=%s node_id=%s", entity_id, map_id, node_id)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "缺少 period:按 R5-补1(期间不可归期)未落库任何数据",
|
||||
"alert_created": False,
|
||||
"alert_skipped_reason": "no_period",
|
||||
"zero_write": True,
|
||||
"review_topic_created": False,
|
||||
}
|
||||
|
||||
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m or (m.entity_id or 1) != entity_id:
|
||||
@@ -95,9 +112,8 @@ def push_deviation_to_map(
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||
|
||||
# 3. 更新实际值到 KPIValue 表
|
||||
if not period:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
|
||||
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
|
||||
existing_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
@@ -106,20 +122,22 @@ def push_deviation_to_map(
|
||||
if existing_value:
|
||||
existing_value.actual_value = new_value
|
||||
existing_value.source_type = "manual"
|
||||
kv_row = existing_value
|
||||
else:
|
||||
kv = KPIValue(
|
||||
kv_row = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=new_value,
|
||||
source_type="manual",
|
||||
)
|
||||
db.add(kv)
|
||||
db.add(kv_row)
|
||||
|
||||
db.flush()
|
||||
|
||||
# 4. 检查是否触发预警
|
||||
alert_created = False
|
||||
alert_id = None
|
||||
alert_skipped_reason = None
|
||||
if kpi.target_value and kpi.target_value > 0:
|
||||
ratio = new_value / kpi.target_value
|
||||
if ratio < 0.7:
|
||||
@@ -132,17 +150,28 @@ def push_deviation_to_map(
|
||||
alert_level = None
|
||||
|
||||
if alert_level:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
|
||||
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
|
||||
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R1_demo_entity_excluded"
|
||||
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R5_non_attributable_period"
|
||||
|
||||
if alert_skipped_reason is None:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
|
||||
# 5. 生成战略回顾会议题
|
||||
review_topic_created = False
|
||||
@@ -180,6 +209,7 @@ def push_deviation_to_map(
|
||||
"new_value": new_value,
|
||||
"period": period,
|
||||
"alert_created": alert_created,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
@@ -194,6 +224,7 @@ def push_deviation_to_map(
|
||||
"new_value": new_value,
|
||||
"alert_created": alert_created,
|
||||
"alert_id": alert_id,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user