479 lines
15 KiB
Python
479 lines
15 KiB
Python
"""战略地图 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)):
|
|
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"})
|
|
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)
|
|
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)
|
|
# 按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:
|
|
dim["objectives"] = [{"name": o.name, "description": o.description or "", "icon": o.icon or "target", "kpis": o.kpis or []} for o in grouped[key]]
|
|
m.dimensions = dims
|
|
|
|
|
|
# ── 战略回顾会 聚合接口 ──────────────────────
|
|
|
|
|
|
@router.get("/{map_id}/review")
|
|
def get_map_review(map_id: int, db: Session = Depends(get_db)):
|
|
"""战略回顾会:返回目标状态、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)
|
|
|
|
# 收集所有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
|
|
lv = latest_values.get(kpi_def.id, {})
|
|
actual = lv.get("actual_value")
|
|
target = kpi_def.target_value
|
|
# 判断红黄绿灯
|
|
level = "gray"
|
|
if actual is not None and target:
|
|
ratio = actual / target
|
|
if ratio >= 0.9:
|
|
level = "green"
|
|
elif ratio >= 0.7:
|
|
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,
|
|
}
|
|
|