"""差异分析→战略地图反打 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.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan import json import logging from datetime import datetime 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), 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") # 1. 查找战略地图 m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: 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: raise HTTPException(404, f"KPI {kpi_code} 不存在") # 3. 更新实际值到 KPIValue 表 if not period: period = datetime.now().strftime("%Y-%m") 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" else: kv = KPIValue( kpi_id=kpi.id, period=period, actual_value=new_value, source_type="manual", ) db.add(kv) db.flush() # 4. 检查是否触发预警 alert_created = False alert_id = 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: alert = KPIAlert( 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 # 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.title == topic_title, ActionPlan.status.in_(["pending", "in_progress"]), ).first() if not existing_topic: topic = ActionPlan( 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, "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, "review_topic_created": review_topic_created, } @router.get("/map-nodes/{map_id}") def get_map_nodes(map_id: int, db: Session = Depends(get_db)): """获取战略地图的全部节点(供反打选择使用)""" m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: 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}