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
262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""差异分析→战略地图反打 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}
|