"""战略地图 API""" from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import Optional from app.database import get_db from app.auth_middleware import require_auth, require_role from app.models import StrategicMap, OperationLog, MapObjective import json router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], dependencies=[Depends(require_role("ceo", "finance"))], ) # ── 四维度模板 ────────────────────────────── STRATEGIC_MAP_TEMPLATE = [ { "key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": [ {"name": "营收目标", "kpis": ["F_REVENUE"]}, {"name": "净利润率", "kpis": ["F_NET_PROFIT"]}, {"name": "现金流", "kpis": ["F_OP_CFLOW"]}, ], }, { "key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": [ {"name": "客户满意度", "kpis": ["C_SATISFACTION"]}, {"name": "市场份额", "kpis": ["C_MARKET_SHARE"]}, {"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]}, ], }, { "key": "process", "name": "内部流程层", "icon": "⚙️", "color": "#67C23A", "objectives": [ {"name": "运营效率", "kpis": ["P_DELIVERY"]}, {"name": "质量合格率", "kpis": ["P_PASS_RATE"]}, ], }, { "key": "learning", "name": "学习成长层", "icon": "📚", "color": "#E6A23C", "objectives": [ {"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]}, {"name": "培训完成率", "kpis": ["L_TRAINING"]}, ], }, ] # ── CRUD ──────────────────────────────────── @router.get("") def list_maps(db: Session = Depends(get_db)): maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all() return {"data": [m_to_dict(m, db) for m in maps]} @router.post("") def create_map(data: dict, db: Session = Depends(get_db)): # 空白地图默认四层空结构(避免dimensions=NULL导致加载报错) if "dimensions" not in data or data.get("dimensions") is None: data = {**data, "dimensions": [ {"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []}, {"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []}, {"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []}, {"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []}, ]} if "canvas_data" not in data or data.get("canvas_data") is None: data = {**data, "canvas_data": {"connections": []}} m = StrategicMap(**data) db.add(m) db.commit() db.refresh(m) _sync_map_objectives(m, db) return m_to_dict(m, db) @router.post("/create-with-template") def create_map_with_template(data: dict, db: Session = Depends(get_db)): """一键创建带四维度模板的战略地图""" m = StrategicMap( title=data.get("title", "新建战略地图"), version=data.get("version", "v1.0"), status="draft", dimensions=STRATEGIC_MAP_TEMPLATE, canvas_data={"connections": []}, ) db.add(m) db.commit() db.refresh(m) _sync_map_objectives(m, db) return m_to_dict(m, db) @router.put("/{map_id}") def update_map(map_id: int, data: dict, db: Session = Depends(get_db)): m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") old_status = m.status for k, v in data.items(): if hasattr(m, k) and v is not None: setattr(m, k, v) db.commit() # 同步目标到map_objectives表 _sync_map_objectives(m, db) # ├─ 版本管理: draft → published 时自动创建快照 if old_status == "draft" and m.status == "published": _auto_snapshot(m, db) return m_to_dict(m, db) # ── 删除地图 ───────────────────────────────── @router.delete("/{map_id}") def delete_map(map_id: int, db: Session = Depends(get_db)): """删除战略地图""" m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") db.delete(m) db.commit() return {"message": "已删除"} @router.post("/batch-delete") def batch_delete_maps(data: dict, db: Session = Depends(get_db)): """批量删除战略地图""" ids = data.get("ids", []) if not ids: raise HTTPException(400, "请选择要删除的地图") deleted = 0 for mid in ids: m = db.query(StrategicMap).filter(StrategicMap.id == mid).first() if m: db.delete(m) deleted += 1 db.commit() return {"message": f"已删除 {deleted} 个地图", "deleted": deleted} # ── 连线管理 ───────────────────────────────── def _get_connections(m: StrategicMap) -> list: if not m.canvas_data: m.canvas_data = {"connections": []} if isinstance(m.canvas_data, str): try: m.canvas_data = json.loads(m.canvas_data) except: m.canvas_data = {"connections": []} if "connections" not in m.canvas_data: m.canvas_data["connections"] = [] return m.canvas_data["connections"] @router.post("/{map_id}/connections") def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)): """新增因果连线: {"from": "learning-0", "to": "process-0"}""" m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") from_id = data.get("from", "") to_id = data.get("to", "") if not from_id or not to_id: raise HTTPException(400, "请提供 from 和 to") # 校验: 不能自连 if from_id == to_id: raise HTTPException(400, "不能自身连线") # 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连) from_dim = from_id.rsplit("-", 1)[0] to_dim = to_id.rsplit("-", 1)[0] conns = _get_connections(m) # 校验: 不能重复 for c in conns: if c.get("from") == from_id and c.get("to") == to_id: raise HTTPException(400, "已存在相同的连线") conns.append({ "from": from_id, "to": to_id, "style": "solid", "effect": data.get("effect", "positive"), "label": data.get("label", ""), }) m.canvas_data["connections"] = conns db.commit() return {"connections": conns} @router.delete("/{map_id}/connections") def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db)): """根据 from/to 删除连线""" m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") from_id = data.get("from", "") to_id = data.get("to", "") conns = _get_connections(m) new_conns = [c for c in conns if not (c.get("from") == from_id and c.get("to") == to_id)] if len(new_conns) == len(conns): raise HTTPException(404, "连线不存在") m.canvas_data["connections"] = new_conns db.commit() return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}} # ── 版本管理 ───────────────────────────────── def _auto_snapshot(m: StrategicMap, db: Session): """发布时自动创建版本快照""" from app.models import StrategicMapVersion import re # 自动递增版本号: 找到最大次版本号 existing = db.query(StrategicMapVersion).filter( StrategicMapVersion.map_id == m.id ).order_by(StrategicMapVersion.id.desc()).first() if existing: match = re.search(r"v(\d+)\.(\d+)", existing.version) if match: major = int(match.group(1)) minor = int(match.group(2)) + 1 new_ver = f"v{major}.{minor}" else: new_ver = "v1.0" else: new_ver = "v1.0" # 确保 JSON 序列化 dims = m.dimensions canvas = m.canvas_data if isinstance(dims, str): try: dims = json.loads(dims) except: dims = [] if isinstance(canvas, str): try: canvas = json.loads(canvas) except: canvas = {"connections": []} snapshot = StrategicMapVersion( map_id=m.id, version=new_ver, dimensions=dims, canvas_data=canvas, comment=f"发布 {new_ver}", ) db.add(snapshot) db.commit() # ── 工具函数 ───────────────────────────────── def m_to_dict(m, db: Session = None): d = {c.name: getattr(m, c.name) for c in m.__table__.columns} if db: _merge_map_objectives(m, db) d["dimensions"] = m.dimensions return d def _sync_map_objectives(m, db): """保存时:将dimensions JSON中的目标同步到map_objectives表""" db.query(MapObjective).filter(MapObjective.map_id == m.id).delete() dims = m.dimensions if isinstance(dims, str): dims = json.loads(dims) dims = dims or [] for dim in dims: for i, obj in enumerate(dim.get("objectives", [])): mo = MapObjective( map_id=m.id, dimension_key=dim.get("key", ""), name=obj.get("name", ""), description=obj.get("description", ""), icon=obj.get("icon", "target"), kpis=obj.get("kpis", []), sort_order=i, ) db.add(mo) db.commit() def _merge_map_objectives(m, db): """读取时:将map_objectives表的数据合并进dimensions JSON""" objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all() if not objs: return dims = m.dimensions if isinstance(dims, str): dims = json.loads(dims) dims = dims or [] # 按dimension_key分组 from collections import defaultdict grouped = defaultdict(list) for o in objs: grouped[o.dimension_key].append(o) for dim in dims: key = dim.get("key", "") if key in grouped: merged = [] for i, o in enumerate(grouped[key]): # 保留原有JSON中的额外字段(targetValue/krs等) existing = dim["objectives"][i] if i < len(dim["objectives"]) else {} merged.append({ "name": o.name, "description": o.description or existing.get("description", ""), "icon": o.icon or existing.get("icon", "target"), "kpis": o.kpis or existing.get("kpis", []), **{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")}, }) dim["objectives"] = merged m.dimensions = dims # ── 战略回顾会 聚合接口 ────────────────────── @router.get("/{map_id}/review") def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db)): """战略回顾会:返回目标状态、KPI值、改善行动 level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI; 不传则返回全部KPI(向后兼容)。 """ from app.models import KPIDefinition, KPIValue, ActionPlan m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") dims = m.dimensions if isinstance(dims, str): dims = json.loads(dims) dims = dims or [] # NULL容错 # 收集所有KPI code all_kpi_codes = set() for dim in dims: for obj in dim.get("objectives", []): for code in obj.get("kpis", []): all_kpi_codes.add(code) # 查询KPI定义 kpi_defs = db.query(KPIDefinition).filter( KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False ).all() if all_kpi_codes else [] kpi_map = {k.kpi_code: k for k in kpi_defs} # 查询最新KPI实际值 kpi_ids = [k.id for k in kpi_defs] latest_values = {} if kpi_ids: # 取每个KPI的最新一条 for kid in kpi_ids: v = db.query(KPIValue).filter( KPIValue.kpi_id == kid ).order_by(KPIValue.calculated_at.desc()).first() if v: latest_values[kid] = { "actual_value": v.actual_value, "period": v.period, "source_type": v.source_type, } # 查询改善行动(按KPI_id关联) action_plans_data = [] if kpi_ids: plans = db.query(ActionPlan).filter( ActionPlan.kpi_id.in_(kpi_ids) ).order_by(ActionPlan.created_at.desc()).all() for p in plans: action_plans_data.append({ "id": p.id, "kpi_id": p.kpi_id, "title": p.title, "assignee": p.assignee, "priority": p.priority, "due_date": p.due_date.isoformat() if p.due_date else None, "status": p.status, "progress": p.progress or 0, "created_at": p.created_at.isoformat() if p.created_at else None, }) # 构建维度目标状态 dim_results = [] total_ok = 0 total_warn = 0 total_err = 0 total_obj_count = 0 focus_items = [] for dim in dims: dim_key = dim.get("key", "") dim_name = dim.get("name", "") dim_icon = dim.get("icon", "") dim_color = dim.get("color", "") objectives = [] for obj in dim.get("objectives", []): total_obj_count += 1 obj_kpis = [] worst_level = "green" for code in obj.get("kpis", []): kpi_def = kpi_map.get(code) if not kpi_def: continue # 规则4: 战略地图默认只显示strategic级KPI(level过滤) if level and kpi_def.kpi_level != level: continue lv = latest_values.get(kpi_def.id, {}) actual = lv.get("actual_value") target = kpi_def.target_value # 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%) level = "gray" if actual is not None and target: ratio = actual / target if ratio >= 0.9: level = "green" elif ratio >= 0.6: level = "yellow" else: level = "red" else: level = "gray" if level == "red": worst_level = "red" elif level == "yellow" and worst_level != "red": worst_level = "yellow" obj_kpis.append({ "kpi_id": kpi_def.id, "kpi_code": code, "kpi_name": kpi_def.kpi_name, "target_value": target, "actual_value": actual, "unit": kpi_def.unit, "level": level, }) obj_item = { "name": obj.get("name", ""), "icon": obj.get("icon", ""), "kpis": obj_kpis, "level": worst_level, "has_data": len(obj_kpis) > 0, } objectives.append(obj_item) if worst_level == "green": total_ok += 1 elif worst_level == "yellow": total_warn += 1 elif worst_level == "red": total_err += 1 # 红色和黄色归入需重点关注 if worst_level in ("red", "yellow"): focus_items.append(obj_item) dim_results.append({ "key": dim_key, "name": dim_name, "icon": dim_icon, "color": dim_color, "objectives": objectives, }) # 排序:红色在前,黄色在后 focus_items.sort(key=lambda x: (0 if x["level"] == "red" else 1, x["name"])) return { "map_id": m.id, "title": m.title, "version": m.version, "status": m.status, "dimensions": dim_results, "summary": { "total": total_obj_count, "green": total_ok, "yellow": total_warn, "red": total_err, "health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0, }, "focus_items": focus_items, "action_plans": action_plans_data, }