init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+403
View File
@@ -0,0 +1,403 @@
"""战略地图 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
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
# ── 四维度模板 ──────────────────────────────
STRATEGIC_MAP_TEMPLATE = [
{
"key": "finance",
"name": "财务维度",
"icon": "💰",
"color": "#409eff",
"objectives": [
{"name": "提升销售总额", "kpis": ["F_REVENUE_001"]},
{"name": "优化利润结构", "kpis": ["F_PROFIT_001"]},
{"name": "降低运营成本", "kpis": ["F_COST_001"]},
],
},
{
"key": "customer",
"name": "客户维度",
"icon": "🤝",
"color": "#67c23a",
"objectives": [
{"name": "扩大客户规模", "kpis": ["C_CUST_001"]},
{"name": "提升客户满意度", "kpis": ["C_CUST_003"]},
{"name": "优化客户结构", "kpis": ["C_CUST_002"]},
],
},
{
"key": "process",
"name": "内部流程",
"icon": "⚙️",
"color": "#e6a23c",
"objectives": [
{"name": "提升运营效率", "kpis": ["P_INV_001"]},
{"name": "优化供应链管理", "kpis": ["P_INV_002"]},
{"name": "确保交付质量", "kpis": ["P_SERVICE_001"]},
],
},
{
"key": "learning",
"name": "学习成长",
"icon": "📚",
"color": "#f56c6c",
"objectives": [
{"name": "提升员工技能", "kpis": ["L_TALENT_001"]},
{"name": "推进数字化转型", "kpis": []},
{"name": "建设人才梯队", "kpis": ["L_TALENT_004", "L_TALENT_003"]},
],
},
]
# ── 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) 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)
return m_to_dict(m)
@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)
return m_to_dict(m)
@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()
# ├─ 版本管理: draft → published 时自动创建快照
if old_status == "draft" and m.status == "published":
_auto_snapshot(m, db)
return m_to_dict(m)
# ── 连线管理 ─────────────────────────────────
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, "不能自身连线")
# 校验: 维度不能相同 (learning-0 和 process-0 的维度不同)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
if from_dim == to_dim:
raise HTTPException(400, "同维度内不能连线")
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):
return {c.name: getattr(m, c.name) for c in m.__table__.columns}
# ── 战略回顾会 聚合接口 ──────────────────────
@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,
}