Files
cma-management/backend/app/api/deviation_push.py
Hermes CI Fix 8c5853f64b 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
2026-09-19 11:12:31 +08:00

262 lines
10 KiB
Python
Raw Permalink 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.
"""差异分析→战略地图反打 API — P1-1
允许从差异分析页面一键回写实际值到战略地图节点,触发预警并生成回顾会议题。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
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
logger = logging.getLogger("cma.deviation_push")
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.post("/push-to-map")
def push_deviation_to_map(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""从差异分析回写实际值到战略地图节点
Body: {
mapId: int,
nodeId: string, // 格式 "dim_key-index" 如 "finance-0"
deviationId: int,
newValue: float,
period: string, // 如 "2026-05"
createReviewTopic: bool
}
"""
map_id = data.get("mapId")
node_id = data.get("nodeId")
deviation_id = data.get("deviationId")
new_value = data.get("newValue")
period = data.get("period")
create_review_topic = data.get("createReviewTopic", True)
if not map_id or not node_id:
raise HTTPException(400, "缺少 mapId 或 nodeId")
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:
raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0"
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
parts = node_id.rsplit("-", 1)
if len(parts) != 2:
raise HTTPException(400, f"节点ID格式错误: {node_id}")
dim_key, obj_index_str = parts
try:
obj_index = int(obj_index_str)
except ValueError:
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
target_dim = None
target_obj = None
for dim in dims:
if dim.get("key") == dim_key:
target_dim = dim
objs = dim.get("objectives", [])
if 0 <= obj_index < len(objs):
target_obj = objs[obj_index]
break
if not target_obj:
raise HTTPException(404, f"未找到节点: {node_id}")
kpi_codes = target_obj.get("kpis", [])
if not kpi_codes:
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
kpi_code = kpi_codes[0]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi or (kpi.entity_id or 1) != entity_id:
raise HTTPException(404, f"KPI {kpi_code} 不存在")
# 3. 更新实际值到 KPIValue 表
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing_value:
existing_value.actual_value = new_value
existing_value.source_type = "manual"
kv_row = existing_value
else:
kv_row = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=new_value,
source_type="manual",
)
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:
alert_level = "red"
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
elif ratio < 0.9:
alert_level = "yellow"
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
else:
alert_level = None
if alert_level:
# 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
if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]),
).first()
if not existing_topic:
topic = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
kpi_id=kpi.id,
title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
assignee=current_user.name if hasattr(current_user, "name") else "",
priority="medium",
status="pending",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(topic)
review_topic_created = True
# 6. 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="deviation_push_to_map",
target_type="map",
target_id=map_id,
detail=json.dumps({
"node_id": node_id,
"deviation_id": deviation_id,
"kpi_code": kpi_code,
"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),
)
db.add(log)
db.commit()
return {
"success": True,
"message": f"已回写至战略地图 [{target_dim.get('name')}→{target_obj.get('name')}]",
"kpi_code": kpi_code,
"kpi_name": kpi.kpi_name,
"new_value": new_value,
"alert_created": alert_created,
"alert_id": alert_id,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}
@router.get("/map-nodes/{map_id}")
def get_map_nodes(map_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""获取战略地图的全部节点(供反打选择使用)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
nodes = []
for dim in dims:
objs = dim.get("objectives", [])
for idx, obj in enumerate(objs):
node_id = f"{dim.get('key')}-{idx}"
nodes.append({
"node_id": node_id,
"dim_key": dim.get("key"),
"dim_name": dim.get("name"),
"dim_icon": dim.get("icon"),
"objective_name": obj.get("name"),
"kpi_codes": obj.get("kpis", []),
})
return {"data": nodes}