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
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+145
View File
@@ -0,0 +1,145 @@
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
import logging
logger = logging.getLogger("cma.action_plans")
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def plan_to_dict(p: ActionPlan) -> dict:
return {
"id": p.id,
"alert_id": p.alert_id,
"kpi_id": p.kpi_id,
"title": p.title,
"description": p.description,
"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,
"result": p.result,
"created_by": p.created_by,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
@router.get("")
def list_plans(
status: Optional[str] = None,
kpi_id: Optional[int] = None,
alert_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""获取行动计划列表"""
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
if status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
if alert_id:
query = query.filter(ActionPlan.alert_id == alert_id)
# business角色只看自己的
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
plans = query.all()
result = []
for p in plans:
item = plan_to_dict(p)
# 附带KPI名称
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
result.append(item)
return {"data": result}
@router.post("")
def create_plan(
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""创建改善行动计划"""
required = ["title", "kpi_id"]
for field in required:
if field not in data:
raise HTTPException(400, f"缺少必填字段: {field}")
plan = ActionPlan(
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
title=data["title"],
description=data.get("description"),
assignee=data.get("assignee"),
priority=data.get("priority", "medium"),
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan_to_dict(plan)
@router.put("/{plan_id}")
def update_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
if "title" in data:
plan.title = data["title"]
if "description" in data:
plan.description = data["description"]
if "assignee" in data:
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
plan.status = data["status"]
if "progress" in data:
plan.progress = max(0, min(100, data["progress"]))
if "result" in data:
plan.result = data["result"]
db.commit()
db.refresh(plan)
return plan_to_dict(plan)
@router.delete("/{plan_id}")
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
db.commit()
return {"message": "已删除"}
+325
View File
@@ -0,0 +1,325 @@
"""AI分析引擎 — 侧边栏智能分析"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, text as sa_text
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan
from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os
from datetime import datetime
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
async def _call_deepseek(prompt: str) -> str:
"""调用DeepSeek API"""
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"https://api.deepseek.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。回答要简洁、专业、有数据支撑。"},
{"role": "user", "content": prompt}
],
"stream": False,
"temperature": 0.3,
}
)
data = resp.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
@router.get("/dashboard-analysis")
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
"""AI分析驾驶舱数据"""
# 尝试缓存
cache_key = f"dashboard_analysis:{role}"
cached = cache_get("ai", cache_key)
if cached:
return cached
# 获取当前KPI数据
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name,
"code": k.kpi_code,
"dimension": k.dimension,
"target": k.target_value,
"actual": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"unit": k.unit,
})
# 获取预警
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
# 构建分析prompt
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
当前KPI数据:
{kpi_text}
待处理预警数:{alerts}
请从以下三个方面分析:
1. **核心发现**:当前数据反映的最关键问题是什么?
2. **深入解读**:从CMA管理会计角度,这些数据意味着什么?
3. **行动建议**:基于数据,财务和业务部门应该采取什么具体行动?
注意:角色视角为{"CEO(总经理)" if role == "ceo" else "财务部" if role == "finance" else "业务部"}"""
try:
analysis = await _call_deepseek(prompt)
except Exception as e:
analysis = f"AI分析暂时不可用: {str(e)}"
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
# 缓存10分钟
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
@router.get("/kpi-analysis/{kpi_id}")
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
"""AI分析单个KPI"""
# 尝试缓存
cache_key = f"kpi_analysis:{kpi_id}"
cached = cache_get("ai", cache_key)
if cached:
return cached
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
trend_data = []
for v in values:
trend_data.append({"period": v.period, "value": v.actual_value})
prompt = f"""请分析以下KPI指标:
KPI名称:{kpi.kpi_name}
维度:{kpi.dimension}
计算公式:{kpi.formula}
目标值:{kpi.target_value}
单位:{kpi.unit}
负责部门:{kpi.responsible_dept}
历史数据趋势:
{json.dumps(trend_data, ensure_ascii=False, indent=2)}
请分析:
1. 当前表现如何,是否达到目标
2. 趋势走势是否健康(上升/下降/波动)
3. 存在什么风险
4. 建议采取什么管理行动"""
try:
analysis = await _call_deepseek(prompt)
except Exception as e:
analysis = f"分析暂时不可用: {str(e)}"
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
async def _stream_analysis(prompt: str):
"""流式调用DeepSeek并生成SSE事件"""
async with httpx.AsyncClient(timeout=60) as client:
async with client.stream(
"POST",
"https://api.deepseek.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
"Content-Type": "application/json",
},
json={
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一名CMA管理会计师,擅长用数据驱动的方式分析企业经营状况,给出专业的财务分析和管理建议。"},
{"role": "user", "content": prompt},
],
"stream": True,
"temperature": 0.3,
}
) as response:
async for line in response.aiter_lines():
if not line or line.startswith(":"):
continue
if line.startswith("data: "):
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
if delta:
yield f"data: {json.dumps({'text': delta})}\n\n"
except json.JSONDecodeError:
continue
yield "data: {\"text\": \"[DONE]\"}\n\n"
@router.get("/dashboard-analysis-stream")
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)):
"""AI分析驾驶舱数据 — SSE流式输出"""
cache_key = f"dashboard_analysis:{role}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存存在,直接以流的形式一次性返回
full_text = cached.get("analysis", "")
async def cached_stream():
yield f"data: {json.dumps({'text': full_text})}\n\n"
yield "data: {\"text\": \"[DONE]\"}\n\n"
return StreamingResponse(cached_stream(), media_type="text/event-stream")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
"target": k.target_value, "actual": latest.actual_value if latest else None,
"period": latest.period if latest else None, "unit": k.unit,
})
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层")
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
当前KPI数据:
{kpi_text}
待处理预警数:{alerts}
请从以下三个方面分析:
1. **核心发现**:当前数据反映的最关键问题是什么?
2. **深入解读**:从CMA管理会计角度,这些数据意味着什么?
3. **行动建议**:基于数据,财务和业务部门应该采取什么具体行动?
注意:角色视角为{role_label}"""
return StreamingResponse(_stream_analysis(prompt), media_type="text/event-stream")
@router.post("/ask")
async def ask_question(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""自然语言查询 — CEO问企业经营问题"""
body = await request.json()
question = body.get("question", "").strip()
if not question:
raise HTTPException(400, "请输入问题")
# 收集系统数据作为上下文
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpi_context = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
kpi_context.append(
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else ''}"
f"{' ⚠️' + alert.alert_level if alert else ''}"
)
# 获取改善计划
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all()
plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans]
# 获取预警
red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count()
yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count()
system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。
时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}
当前用户:{current_user.name} ({current_user.role})
## KPI数据
{chr(10).join(kpi_context)}
## 预警概况
红色(紧急): {red_alerts}条 | 黄色(预警): {yellow_alerts}
## 改善计划
{chr(10).join(plan_context) if plan_context else '暂无'}
请基于以上数据回答问题。如果问题需要具体数据但上下文中没有,可以根据KPI编码名称推断。回答要简洁、有数据支撑。"""
prompt = f"{system_context}\n\n用户问题:{question}"
try:
analysis = await _call_deepseek(prompt)
except Exception as e:
analysis = f"查询失败: {str(e)}"
return {"question": question, "answer": analysis, "timestamp": datetime.now().isoformat()}
@router.post("/review-plans")
async def review_plans(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""AI复盘改善行动计划执行效果"""
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all()
if not plans:
return {"analysis": "暂无改善行动计划,无法复盘"}
plan_text = []
for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知"
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first()
plan_text.append(
f"- {p.title}\n"
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else ''})\n"
f" 负责人: {p.assignee} | 状态: {p.status} | 进度: {p.progress}%\n"
f" 描述: {p.description}\n"
f" 截止日: {p.due_date.strftime('%Y-%m-%d') if p.due_date else ''}"
)
completed = sum(1 for p in plans if p.status == "completed")
in_progress = sum(1 for p in plans if p.status == "in_progress")
pending = sum(1 for p in plans if p.status == "pending")
prompt = f"""请复盘以下改善行动计划的执行情况:
## 改善计划概览
总数: {len(plans)} | 已完成: {completed} | 进行中: {in_progress} | 待开始: {pending}
## 各计划详情
{chr(10).join(plan_text)}
请分析:
1. **执行概况**:整体执行到位吗?哪些计划需要重点关注?
2. **效果评估**:已完成的计划是否真正改善了关联KPI?
3. **风险提示**:哪些计划存在延期或执行不力的风险?
4. **改进建议**:接下来应该调整或优先推进哪些计划?"""
try:
analysis = await _call_deepseek(prompt)
except Exception as e:
analysis = f"复盘失败: {str(e)}"
return {
"analysis": analysis,
"stats": {"total": len(plans), "completed": completed, "in_progress": in_progress, "pending": pending},
"timestamp": datetime.now().isoformat(),
}
+76
View File
@@ -0,0 +1,76 @@
"""预警规则配置"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, KPIDefinition, KPIValue
router = APIRouter(prefix="/api/cma/alert-rules", tags=["预警规则"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
@router.get("")
def list_rules(kpi_id: int = None, db: Session = Depends(get_db)):
"""获取预警规则(从KPI定义中读取阈值配置)"""
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
rules = []
for k in query.all():
if k.threshold_green or k.threshold_yellow or k.threshold_red:
rules.append({
"kpi_id": k.id,
"kpi_name": k.kpi_name,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": rules}
@router.post("/check/{kpi_id}")
def check_alert(kpi_id: int, db: Session = Depends(get_db)):
"""检查指定KPI是否需要触发预警"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).first()
if not latest or not latest.actual_value:
return {"alert": False, "message": "无数据"}
val = latest.actual_value
level = "green"
# 简单阈值判定
red = kpi.threshold_red
yellow = kpi.threshold_yellow
# 红灯判断: <3000000 表示低于300万触发红灯
if red:
if "<" in red:
limit = float(red.split("<")[1].strip())
if val < limit: level = "red"
elif ">" in red:
limit = float(red.split(">")[1].strip())
if val > limit: level = "red"
# 黄灯判断(红灯未触发时)
if level == "green" and yellow:
if "<" in yellow:
limit = float(yellow.split("<")[1].strip())
if val < limit: level = "yellow"
elif ">" in yellow:
limit = float(yellow.split(">")[1].strip())
if val > limit: level = "yellow"
if level != "green":
alert = KPIAlert(
kpi_id=kpi_id, kpi_value_id=latest.id,
alert_level=level,
alert_message=f"{kpi.kpi_name}当前值为{val},触发{level}预警",
)
db.add(alert)
db.commit()
return {"alert": True, "level": level, "message": alert.alert_message}
return {"alert": False, "level": "green", "message": "正常"}
+30
View File
@@ -0,0 +1,30 @@
"""预警 API"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
query = db.query(KPIAlert)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
@router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if alert:
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
return {"message": "已处理", "assignee": alert.assignee}
+309
View File
@@ -0,0 +1,309 @@
"""KPI目标对齐管理 API — 管理会计OS
支持三种对齐模式:纵向分解 / 横向支撑 / BSC瀑布链
管理员可初始化选择,后续按模式运作"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime
import json
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, OperationLog, RolePermission
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
# 不设全局权限,每个接口单独控制
)
# 三种对齐模式定义
ALIGNMENT_MODES = [
{
"key": "vertical_decomposition",
"name": "纵向分解",
"description": "上级KPI直接拆分为多个下级KPI,目标值汇总等于上级目标。适用于营收、成本等可量化指标。",
"example": "公司销售总额2000万 → 区域A 800万 + 区域B 700万 + 区域C 500万",
},
{
"key": "horizontal_support",
"name": "横向支撑",
"description": "下级KPI是上级KPI的驱动因子,下级目标达成支撑上级结果。适用于复合型指标。",
"example": "销售毛利率30% ← 销售总额↑ + 成本控制↓ + 高毛利产品占比↑",
},
{
"key": "bsc_chain",
"name": "BSC瀑布链",
"description": "按平衡计分卡因果链层层传导:学习成长→内部流程→客户→财务。",
"example": "培训完成率↑ → 订单交付及时率↑ → 客户满意度↑ → 销售总额↑",
},
]
@router.get("/modes")
def list_modes():
"""返回三种对齐模式的定义(公开接口)"""
return {"modes": ALIGNMENT_MODES}
@router.get("/config")
def get_alignment_config(db: Session = Depends(get_db)):
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
if not perm:
return {
"mode": None,
"configured": False,
"modes": ALIGNMENT_MODES,
}
return {
"mode": perm.value,
"configured": True,
"modes": ALIGNMENT_MODES,
}
@router.post("/config")
def set_alignment_config(
data: dict,
db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")),
):
"""初始化/修改系统对齐模式(CEO/IT权限)"""
mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
if perm:
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
else:
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
db.add(perm)
db.commit()
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
@router.get("/tree")
def get_alignment_tree(
kpi_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
user = Depends(require_auth),
):
"""获取KPI对齐关系树
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
如果指定kpi_id,返回该KPI及其下级树;
如果不指定,返回整个对齐树。
"""
# 获取当前模式
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
mode = config_perm.value.get("mode") if config_perm else None
if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
# 获取所有KPI
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
kpi_map = {k.id: k for k in kpis}
# 构建父子关系
if mode == "vertical_decomposition":
# 纵向分解:BSC编码前缀相同=同一系列
return _build_vertical_tree(kpis, kpi_id)
elif mode == "horizontal_support":
# 横向支撑:按BSC维度+类别的因果关系
return _build_horizontal_tree(kpis, kpi_id)
elif mode == "bsc_chain":
# BSC瀑布链:按维度层级传导
return _build_bsc_chain(kpis, kpi_id)
else:
raise HTTPException(400, f"未知的对齐模式: {mode}")
def _build_vertical_tree(kpis, kpi_id=None):
"""纵向分解树:按编码前缀分组,同一前缀=同一系列"""
from collections import defaultdict
# 提取前缀(如 F_REVENUE_001 → F_REVENUE
groups = defaultdict(list)
for k in kpis:
parts = k.kpi_code.rsplit("_", 1)
prefix = parts[0] if len(parts) > 1 else k.kpi_code
groups[prefix].append(k)
def make_node(kpi):
return {
"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"dimension": kpi.dimension, "category": kpi.category,
"target_value": kpi.target_value, "unit": kpi.unit,
"children": [],
}
trees = []
# 每个前缀组中,按序号升序,第一个为父级
for prefix, group in sorted(groups.items()):
sorted_group = sorted(group, key=lambda k: k.kpi_code)
if len(sorted_group) > 1:
parent = make_node(sorted_group[0])
parent["children"] = [make_node(c) for c in sorted_group[1:]]
for c in parent["children"]:
c["alignment_type"] = "vertical_split"
c["parent_code"] = parent["kpi_code"]
parent["child_count"] = len(parent["children"])
trees.append(parent)
else:
trees.append(make_node(sorted_group[0]))
if kpi_id:
# 只返回指定KPI的子树
return _filter_tree(trees, kpi_id)
return {"mode": "vertical_decomposition", "mode_name": "纵向分解", "tree": trees, "total": len(kpis)}
def _build_horizontal_tree(kpis, kpi_id=None):
"""横向支撑树:按BSC维度因果关联"""
# 因果顺序:learning → process → customer → finance
dim_order = {"learning": 0, "process": 1, "customer": 2, "finance": 3}
dim_name = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"}
# 按维度分组
groups = {"finance": [], "customer": [], "process": [], "learning": []}
for k in kpis:
if k.dimension in groups:
groups[k.dimension].append(k)
def make_node(kpi):
return {
"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"dimension": kpi.dimension, "category": kpi.category,
"target_value": kpi.target_value, "unit": kpi.unit,
"children": [],
}
# 构建层级:一个维度节点包含该维度所有KPI
trees = []
for dim, ks in sorted(groups.items(), key=lambda x: dim_order.get(x[0], 9)):
if not ks:
continue
dim_node = {
"id": None,
"dimension": dim,
"kpi_name": dim_name.get(dim, dim),
"is_dimension_group": True,
"children": [make_node(k) for k in sorted(ks, key=lambda x: x.kpi_code)],
"child_count": len(ks),
}
# 建立因果关联说明
if dim == "learning":
dim_node["description"] = "驱动因素:人才培养与创新"
for c in dim_node["children"]:
c["drives"] = "internal_process"
elif dim == "process":
dim_node["description"] = "过程保障:效率与质量提升"
for c in dim_node["children"]:
c["drives"] = "customer"
elif dim == "customer":
dim_node["description"] = "市场反馈:客户规模与满意度"
for c in dim_node["children"]:
c["drives"] = "finance"
elif dim == "finance":
dim_node["description"] = "结果指标:收入与盈利"
for c in dim_node["children"]:
c["drives"] = None
trees.append(dim_node)
if kpi_id:
return _filter_tree(trees, kpi_id)
return {
"mode": "horizontal_support",
"mode_name": "横向支撑",
"tree": trees,
"total": len(kpis),
"causal_chain": [
{"from": "学习成长", "to": "内部流程", "logic": "培训与创新→流程效率提升"},
{"from": "内部流程", "to": "客户", "logic": "流程效率→客户满意度提升"},
{"from": "客户", "to": "财务", "logic": "客户规模→财务结果达成"},
],
}
def _build_bsc_chain(kpis, kpi_id=None):
"""BSC瀑布链:按category类别间的因果传导"""
from collections import defaultdict
# 每个维度的KPI按category分组
cat_kpis = defaultdict(list)
for k in kpis:
if k.category:
cat_kpis[k.category].append(k)
# BSC瀑布链的传导关系
chain = [
{"cat": "talent_pipeline", "label": "人才梯队", "dim": "learning", "feeds": ["supply_chain", "delivery_quality"]},
{"cat": "employee_engagement", "label": "员工敬业", "dim": "learning", "feeds": ["supply_chain"]},
{"cat": "innovation", "label": "创新改善", "dim": "learning", "feeds": ["delivery_quality"]},
{"cat": "supply_chain", "label": "供应链效率", "dim": "process", "feeds": ["delivery_quality"]},
{"cat": "delivery_quality", "label": "交付质量", "dim": "process", "feeds": ["customer_scale", "customer_satisfaction"]},
{"cat": "customer_scale", "label": "客户规模", "dim": "customer", "feeds": ["revenue_growth"]},
{"cat": "customer_concentration", "label": "客户集中度", "dim": "customer", "feeds": ["profitability"]},
{"cat": "customer_satisfaction", "label": "客户满意", "dim": "customer", "feeds": ["revenue_growth", "profitability"]},
{"cat": "revenue_growth", "label": "收入增长", "dim": "finance", "feeds": ["profitability"]},
{"cat": "profitability", "label": "盈利水平", "dim": "finance", "feeds": None},
{"cat": "cost_control", "label": "成本费用", "dim": "finance", "feeds": ["profitability"]},
{"cat": "asset_efficiency", "label": "资产效率", "dim": "finance", "feeds": ["profitability"]},
{"cat": "cash_risk", "label": "现金流风控", "dim": "finance", "feeds": ["profitability"]},
]
def make_node(kpi):
return {
"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"dimension": kpi.dimension, "category": kpi.category,
"target_value": kpi.target_value, "unit": kpi.unit,
}
# 构建瀑布链
trees = []
for link in chain:
cat = link["cat"]
if cat not in cat_kpis:
continue
cat_node = {
"id": None,
"category": cat,
"category_label": link["label"],
"dimension": link["dim"],
"is_category_group": True,
"feeds": link["feeds"],
"children": [make_node(k) for k in sorted(cat_kpis[cat], key=lambda x: x.kpi_code)],
"child_count": len(cat_kpis[cat]),
}
trees.append(cat_node)
if kpi_id:
return _filter_tree(trees, kpi_id)
return {
"mode": "bsc_chain",
"mode_name": "BSC瀑布链",
"tree": trees,
"total": len(kpis),
"chain": chain,
}
def _filter_tree(nodes, target_id):
"""在树中查找包含指定KPI的子树"""
for node in nodes:
if node.get("id") == target_id:
return node
if node.get("children"):
for child in node["children"]:
if child.get("id") == target_id:
return child
# 递归查找
found = _filter_tree(node["children"], target_id)
if found:
return found
return None
+70
View File
@@ -0,0 +1,70 @@
"""用户认证"""
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.auth_middleware import create_token, require_auth, ROLES
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
@router.post("/login")
def login(data: dict, db: Session = Depends(get_db)):
username = data.get("username", "")
password = data.get("password", "")
user = db.query(User).filter(User.username == username).first()
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
raise HTTPException(401, "用户名或密码错误")
token = create_token(user.id)
return {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"name": user.name,
"role": user.role,
"role_name": ROLES.get(user.role, {}).get("name", user.role),
}
}
@router.post("/register")
def register(data: dict, db: Session = Depends(get_db)):
exist = db.query(User).filter(User.username == data.get("username")).first()
if exist:
raise HTTPException(400, "用户名已存在")
user = User(
username=data["username"],
password_hash=hashlib.sha256(data["password"].encode()).hexdigest(),
name=data.get("name", data["username"]),
role=data.get("role", "business"),
)
db.add(user)
db.commit()
return {"message": "注册成功"}
@router.get("/me")
def get_me(current_user: User = Depends(require_auth)):
"""获取当前用户信息"""
return {
"id": current_user.id,
"username": current_user.username,
"name": current_user.name,
"role": current_user.role,
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
"phone": current_user.phone,
}
@router.get("/roles")
def list_roles():
"""返回角色列表(给前端用)"""
return {
"data": [
{"code": k, "name": v["name"], "priority": v["priority"]}
for k, v in ROLES.items()
]
}
+336
View File
@@ -0,0 +1,336 @@
"""预算管理 API — 管理会计OS
预算值的CRUD、自动分解、版本管理
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import BudgetPlan, KPIDefinition, OperationLog
router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("/plans")
def list_budget_plans(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
year: Optional[int] = Query(None),
version: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""查询预算计划列表"""
query = db.query(BudgetPlan).join(
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
)
if kpi_id:
query = query.filter(BudgetPlan.kpi_id == kpi_id)
if period:
query = query.filter(BudgetPlan.period == period)
if year:
query = query.filter(BudgetPlan.budget_year == year)
if version:
query = query.filter(BudgetPlan.version == version)
plans = query.order_by(BudgetPlan.budget_year.desc(), BudgetPlan.budget_month.asc()).all()
result = []
for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
result.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"period": p.period,
"budget_value": p.budget_value,
"budget_year": p.budget_year,
"budget_month": p.budget_month,
"version": p.version,
"status": p.status,
"remark": p.remark,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/plans")
def create_budget_plan(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""创建或更新单条预算计划"""
kpi_id = data.get("kpi_id")
period = data.get("period")
budget_value = data.get("budget_value")
if not all([kpi_id, period, budget_value is not None]):
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
# 检查KPI是否存在
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
year, month = period.split("-")
version = data.get("version", "v1.0")
# 检查是否已有记录(去重)
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = budget_value
existing.remark = data.get("remark", existing.remark)
db.commit()
db.refresh(existing)
return {"message": "预算已更新", "id": existing.id}
else:
plan = BudgetPlan(
kpi_id=kpi_id,
period=period,
budget_value=budget_value,
budget_year=int(year),
budget_month=int(month),
version=version,
status="active",
remark=data.get("remark", ""),
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(plan)
db.commit()
db.refresh(plan)
# 记录操作日志
log = OperationLog(
action="create",
target_type="budget",
target_id=plan.id,
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {"message": "预算已创建", "id": plan.id}
@router.put("/plans/{plan_id}")
def update_budget_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
if "budget_value" in data:
plan.budget_value = data["budget_value"]
if "remark" in data:
plan.remark = data["remark"]
if "version" in data:
plan.version = data["version"]
if "status" in data:
plan.status = data["status"]
db.commit()
return {"message": "预算已更新"}
@router.delete("/plans/{plan_id}")
def delete_budget_plan(
plan_id: int,
db: Session = Depends(get_db),
):
"""删除预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
db.delete(plan)
db.commit()
return {"message": "预算已删除"}
@router.post("/auto-decompose")
def auto_decompose_budget(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""自动分解年度预算到月度(均分或按历史权重)"""
kpi_id = data.get("kpi_id")
year = data.get("year", datetime.now().year)
annual_budget = data.get("annual_budget")
method = data.get("method", "equal") # equal / weighted
version = data.get("version", "v1.0")
if not kpi_id or annual_budget is None:
raise HTTPException(400, "缺少必要参数: kpi_id, annual_budget")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 计算各月权重
if method == "weighted":
# 按去年各月实际值的比例分配
last_year = year - 1
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period.like(f"{last_year}-%"),
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.asc()).all()
total = sum(v.actual_value for v in values)
if total > 0:
weights = {v.period: v.actual_value / total for v in values}
else:
method = "equal"
created = []
for m in range(1, 13):
period = f"{year}-{m:02d}"
weight = weights.get(period, 1 / 12) if method == "weighted" else 1 / 12
monthly_value = round(annual_budget * weight, 2)
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = monthly_value
else:
bp = BudgetPlan(
kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(bp)
created.append({"period": period, "value": monthly_value})
else:
# 均分
monthly = round(annual_budget / 12, 2)
created = []
for m in range(1, 13):
period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = monthly
else:
bp = BudgetPlan(
kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(bp)
created.append({"period": period, "value": monthly})
db.commit()
return {
"message": f"年度预算已分解为{len(created)}个月度预算",
"kpi_id": kpi_id,
"kpi_name": kpi.kpi_name,
"year": year,
"annual_budget": annual_budget,
"method": method,
"monthly_budgets": created,
}
@router.get("/deviation-report")
def get_deviation_report(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
if period is None:
if year and month:
period = f"{year}-{month:02d}"
elif year:
period = f"{year}-{datetime.now().month:02d}"
else:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.all()
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
items = []
summary = {
"total_kpis": 0,
"has_budget": 0,
"over_budget": 0,
"under_budget": 0,
"avg_deviation_rate": 0,
}
rates = []
for kpi in kpis:
item = calc_period_deviation(db, kpi.id, period)
items.append(item)
summary["total_kpis"] += 1
if item.get("budget_value") is not None:
summary["has_budget"] += 1
if item.get("is_over_budget"):
summary["over_budget"] += 1
elif item.get("deviation_rate") is not None and item["deviation_rate"] < 0:
summary["under_budget"] += 1
if item.get("deviation_rate") is not None:
rates.append(abs(item["deviation_rate"]))
# 补充同比/环比
if item.get("actual_value") is not None:
item["yoy"] = calc_period_diff(db, kpi.id, period, "yoy")
item["mom"] = calc_period_diff(db, kpi.id, period, "mom")
summary["avg_deviation_rate"] = round(sum(rates) / len(rates), 2) if rates else 0
# 前端 alert_level 过滤
if alert_level:
def get_level(rate):
if rate is None:
return None
if rate > 20:
return "red"
if rate > 10:
return "yellow"
return "normal"
items = [i for i in items if get_level(i.get("deviation_rate")) == alert_level]
return {
"period": period,
"summary": summary,
"items": items,
}
+237
View File
@@ -0,0 +1,237 @@
"""成本分析API — 管理会计OS"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.utils.cost_engine import (
calc_product_variance, get_cost_overview, get_cost_breakdown,
calc_driver_rate, allocate_cost
)
logger = logging.getLogger("cma.cost")
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
# ============================================================
# 标准成本卡片 CRUD
# ============================================================
@router.get("/standard-costs")
def list_standard_costs(product_code: Optional[str] = Query(None),
cost_type: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询标准成本卡片"""
query = db.query(StandardCost).filter(StandardCost.status == "active")
if product_code:
query = query.filter(StandardCost.product_code == product_code)
if cost_type:
query = query.filter(StandardCost.cost_type == cost_type)
items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all()
return {"data": items}
@router.post("/standard-costs")
def create_standard_cost(data: dict, db: Session = Depends(get_db)):
"""创建标准成本卡片"""
sc = StandardCost(
product_code=data["product_code"],
product_name=data.get("product_name", ""),
cost_type=data["cost_type"],
item_name=data["item_name"],
standard_quantity=data["standard_quantity"],
unit=data.get("unit", ""),
standard_price=data["standard_price"],
standard_cost=round(data["standard_quantity"] * data["standard_price"], 2),
version=data.get("version", "v1.0"),
remark=data.get("remark"),
)
db.add(sc)
db.commit()
return {"message": "标准成本已创建", "id": sc.id}
@router.put("/standard-costs/{cost_id}")
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db)):
"""修改标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
for k in ("product_code", "product_name", "cost_type", "item_name",
"standard_quantity", "unit", "standard_price", "version", "remark"):
if k in data:
setattr(sc, k, data[k])
sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2)
db.commit()
return {"message": "已更新"}
@router.delete("/standard-costs/{cost_id}")
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
"""删除标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
sc.status = "archived"
db.commit()
return {"message": "已归档"}
# ============================================================
# 实际成本 CRUD
# ============================================================
@router.get("/actual-costs")
def list_actual_costs(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询实际成本"""
query = db.query(ActualCost)
if period:
query = query.filter(ActualCost.period == period)
if product_code:
query = query.filter(ActualCost.product_code == product_code)
items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all()
return {"data": items}
@router.post("/actual-costs")
def create_actual_cost(data: dict, db: Session = Depends(get_db)):
"""录入实际成本"""
ac = ActualCost(
period=data["period"],
product_code=data["product_code"],
product_name=data.get("product_name", ""),
cost_type=data["cost_type"],
item_name=data.get("item_name", ""),
actual_quantity=data["actual_quantity"],
actual_price=data["actual_price"],
actual_cost=round(data["actual_quantity"] * data["actual_price"], 2),
source=data.get("source", "manual"),
)
db.add(ac)
db.commit()
return {"message": "实际成本已录入", "id": ac.id}
# ============================================================
# ABC 作业成本
# ============================================================
@router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db)):
"""查询ABC作业中心列表"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
return {"data": items}
@router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
"""创建ABC作业中心"""
act = AbcActivity(
activity_code=data["activity_code"],
activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"),
cost_driver=data["cost_driver"],
driver_unit=data.get("driver_unit"),
total_cost=data.get("total_cost", 0),
driver_volume=data.get("driver_volume", 0),
)
act.driver_rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0
db.add(act)
db.commit()
return {"message": "作业中心已创建", "id": act.id}
@router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db)):
"""执行ABC成本分配"""
result = allocate_cost(
activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"],
product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"],
)
return result
@router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询ABC分配记录"""
query = db.query(AbcAllocation)
if period:
query = query.filter(AbcAllocation.period == period)
if product_code:
query = query.filter(AbcAllocation.product_code == product_code)
items = query.order_by(AbcAllocation.period.desc()).all()
return {"data": items}
# ============================================================
# 分析看板
# ============================================================
@router.get("/overview")
def cost_overview(period: Optional[str] = Query(None)):
"""成本总览(总成本、结构占比、趋势)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_overview(period)
@router.get("/variance")
def cost_variance(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""量差价差分析"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return calc_product_variance(product_code, period)
@router.get("/breakdown")
def cost_breakdown(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""成本构成(料/工/费占比)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_breakdown(product_code, period)
@router.get("/dashboard")
def cost_dashboard(period: Optional[str] = Query(None)):
"""成本分析首页—汇总数据"""
if period is None:
period = datetime.now().strftime("%Y-%m")
overview = get_cost_overview(period)
# 获取所有产品列表
db = get_db().__next__()
try:
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.period == period
).distinct().all()
product_list = [{"code": p[0], "name": p[1]} for p in products]
# 各产品成本
product_costs = []
for code, name in products:
costs = db.query(ActualCost).filter(
ActualCost.product_code == code,
ActualCost.period == period,
).all()
total = round(sum(c.actual_cost for c in costs), 2)
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
finally:
db.close()
return {
"period": period,
"overview": overview,
"products": product_list,
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
}
+495
View File
@@ -0,0 +1,495 @@
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.cache import get as cache_get, set as cache_set
import json
import logging
logger = logging.getLogger("cma.dashboard")
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
today = datetime.now()
if period_type == "month":
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "quarter":
q = (today.month - 1) // 3
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "year":
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "custom" and start_date and end_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
else:
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
return start, end
def period_prefix(period_type: str):
"""生成SQL期间前缀匹配"""
if period_type == "month":
return datetime.now().strftime("%Y-%m")
elif period_type == "quarter":
now = datetime.now()
q = (now.month - 1) // 3
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
return months
elif period_type == "year":
return str(datetime.now().year)
return None
@router.get("/summary")
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
cache_key = f"summary:{role}:{period}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
# 读取最近一次同步状态(从日志文件最后一行)
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
try:
with open("/var/log/cma-daily-sync.log", "r") as f:
lines = f.readlines()
# 从最后往前找包含 "完成" 或 "失败" 的行
for line in reversed(lines[-50:]):
if "全部完成" in line:
sync_status["status"] = "success"
sync_status["last_sync"] = line.strip()
break
elif "失败" in line or "ERROR" in line:
sync_status["status"] = "failed"
sync_status["last_sync"] = line.strip()
break
else:
# 没找到完成/失败标记,取最后一行
sync_status["last_sync"] = lines[-1].strip() if lines else None
except Exception as e:
sync_status["detail"] = str(e)
result = {
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
"sync_status": sync_status,
}
cache_set("dashboard", cache_key, result, ttl_seconds=30)
return result
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
if period == "month":
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
months = period_prefix("quarter")
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
})
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
@router.get("/my-kpis")
def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
- 其他角色:看所有有预警的KPI
"""
role = current_user.role
username = current_user.username
name = current_user.name
period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
# business角色筛选
if role == "business":
responsible = (k.responsible_user or "").strip()
if responsible and responsible != username and responsible != name:
continue
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"alert_id": alert.id if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
@router.get("/finance-analysis")
def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""财务工作台分析数据"""
period_str = datetime.now().strftime("%Y-%m")
finance_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "finance",
).all()
kpi_data = []
for k in finance_kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
kpi_data.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"trend": trend,
"alert_level": alert.alert_level if alert else "none",
"frequency": k.frequency,
})
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
return {
"period": period_str,
"kpis": kpi_data,
"summary": {
"total_sales": total_sales["actual_value"] if total_sales else None,
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
"receivable_turnover": receivable["actual_value"] if receivable else None,
}
}
@router.get("/predict")
def predict_kpis(db: Session = Depends(get_db)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
period_str = datetime.now().strftime("%Y-%m")
next_month = int(period_str[5:7]) + 1
next_year = int(period_str[:4])
if next_month > 12:
next_month = 1
next_year += 1
next_period = f"{next_year}-{next_month:02d}"
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
predictions = []
for k in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.asc()).all()
# 需要至少3个数据点才能做预测
if len(values) < 3:
continue
# 简单线性回归: y = a + bx
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
if len(points) < 3:
continue
n = len(points)
sum_x = sum(p[0] for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum(p[0] * p[1] for p in points)
sum_xx = sum(p[0] ** 2 for p in points)
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
denom = n * sum_xx - sum_x * sum_x
if denom == 0:
continue
b = (n * sum_xy - sum_x * sum_y) / denom
a = (sum_y - b * sum_x) / n
# 预测下个月(x = n,因为最后一个索引是 n-1)
predicted_value = a + b * n
# 检查预测值是否触发阈值
alert_level = "none"
if k.threshold_red:
op = k.threshold_red[:2] if k.threshold_red[1] in "=<>" else k.threshold_red[0]
val = float(k.threshold_red.replace(op, "").strip())
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "red"
if alert_level == "none" and k.threshold_yellow:
op = k.threshold_yellow[:2] if k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
val = float(k.threshold_yellow.replace(op, "").strip())
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "yellow"
predictions.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"target_value": k.target_value,
"last_value": points[-1][1] if points else None,
"predicted_value": round(predicted_value, 2),
"predicted_period": next_period,
"alert_level": alert_level,
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
"data_points": len(points),
})
return {
"current_period": period_str,
"next_period": next_period,
"predictions": predictions,
"kpi_count": len(kpis),
"predictable_count": len(predictions),
}
# ── 个人工作台 ──────────────────────────────
@router.get("/my-dashboard")
def my_dashboard(
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
username = current_user.username
name = current_user.name
role = current_user.role
# 角色预设KPI编码
ROLE_PRESET_KPIS = {
"ceo": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "C_CUST_001", "P_INV_001"],
"finance": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "F_CASH_001"],
"business": ["C_CUST_001", "C_CUST_003", "F_REVENUE_001"],
"it": [], # IT没有固定预设
}
preset_codes = ROLE_PRESET_KPIS.get(role, [])
# 1. 我的KPIresponsible_user匹配用户名或姓名)+ 角色预设
assigned_kpis = db.query(KPIDefinition).filter(
or_(
KPIDefinition.responsible_user == username,
KPIDefinition.responsible_user == name,
),
KPIDefinition.status == "active",
).all()
assigned_ids = {k.id for k in assigned_kpis}
# 补充角色预设KPI(去重)
preset_kpis = []
if preset_codes:
preset_kpis = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(preset_codes),
KPIDefinition.status == "active",
~KPIDefinition.id.in_(assigned_ids) if assigned_ids else True,
).all()
all_kpis = assigned_kpis + preset_kpis
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = k.target_value
level = "gray"
if actual is not None and target:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
kpi_list.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"target_value": target,
"actual_value": actual,
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
})
# 2. 我的改善行动(assignee匹配)
from app.models import ActionPlan
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
overdue = False
if p.due_date and p.status not in ("completed", "cancelled"):
overdue = p.due_date < datetime.now()
kpi_name = ""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
if kpi:
kpi_name = kpi.kpi_name
plan_list.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_name": kpi_name,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"status": p.status,
"progress": p.progress or 0,
"due_date": p.due_date.isoformat() if p.due_date else None,
"overdue": overdue,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 3. 待办提醒
reminders = []
# 逾期行动
for p in plan_list:
if p["overdue"]:
reminders.append({
"type": "overdue_plan",
"severity": "danger",
"message": f"你负责的「{p['title']}」已逾期",
"related_id": p["id"],
"related_type": "action_plan",
})
# 红色预警KPI
for k in kpi_list:
if k["level"] == "red":
reminders.append({
"type": "red_kpi",
"severity": "danger",
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
"related_id": k["id"],
"related_type": "kpi",
})
# 黄色预警KPI
for k in kpi_list:
if k["level"] == "yellow":
reminders.append({
"type": "yellow_kpi",
"severity": "warning",
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
"related_id": k["id"],
"related_type": "kpi",
})
return {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
+100
View File
@@ -0,0 +1,100 @@
"""数据对接 API"""
import pandas as pd
import io, json, hashlib
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIValue, DataSourceConfig, OperationLog
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.post("/import-excel")
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
required = ["kpi_code", "period", "actual_value"]
if not all(c in df.columns for c in required):
raise HTTPException(400, f"Excel必须包含列: {required}")
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
for _, row in df.iterrows():
kpi_code = str(row.get("kpi_code", ""))
period = str(row.get("period", ""))
value = row.get("actual_value")
if not kpi_code or not period or pd.isna(value):
continue
from app.models import KPIDefinition
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
continue
kv = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=float(value),
source_type="excel",
source_batch=batch,
data_status="pending",
)
db.add(kv)
count += 1
db.commit()
return {"message": f"导入成功 {count} 条数据", "batch": batch}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db)):
sources = db.query(DataSourceConfig).all()
return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]}
@router.post("/sources")
def create_source(data: dict, db: Session = Depends(get_db)):
source = DataSourceConfig(
name=data.get("name", ""),
source_type=data.get("source_type", "manual"),
api_endpoint=data.get("api_endpoint"),
api_key=data.get("api_key"),
query_sql=data.get("query_sql"),
sync_type=data.get("sync_type", "manual"),
status="active",
)
db.add(source)
db.commit()
db.refresh(source)
# 操作日志
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.put("/sources/{source_id}")
def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
if not source:
raise HTTPException(404, "数据源不存在")
for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]:
if key in data:
setattr(source, key, data[key])
db.commit()
db.refresh(source)
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.delete("/sources/{source_id}")
def delete_source(source_id: int, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
if not source:
raise HTTPException(404, "数据源不存在")
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
db.delete(source)
db.commit()
return {"message": "删除成功"}
+169
View File
@@ -0,0 +1,169 @@
"""KPI字典 API"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime
import json
from app.database import get_db
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 写操作只允许 ceo/finance/it
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
@router.get("")
def list_kpis(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
dimension: Optional[str] = None,
keyword: Optional[str] = None,
epic: Optional[str] = None,
category: Optional[str] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
# 角色权限过滤
dims = kpi_visible_dims(current_user.role, db)
if dims:
query = query.filter(KPIDefinition.dimension.in_(dims))
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
if epic:
query = query.filter(KPIDefinition.epic == epic)
if category:
query = query.filter(KPIDefinition.category == category)
total = query.count()
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
@router.get("/categories")
def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depends(get_db)):
"""获取BSC分类结构(带可见性过滤)"""
from sqlalchemy import func as sa_func
dims = kpi_visible_dims(current_user.role, db)
query = db.query(
KPIDefinition.dimension,
KPIDefinition.category,
sa_func.count(KPIDefinition.id)
).filter(KPIDefinition.status == "active")
if dims:
query = query.filter(KPIDefinition.dimension.in_(dims))
rows = query.group_by(KPIDefinition.dimension, KPIDefinition.category).all()
# 构建树形结构
dim_map = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"}
cat_map = {
"revenue_growth": "收入增长", "profitability": "盈利水平", "cost_control": "成本费用",
"asset_efficiency": "资产效率", "cash_risk": "现金流风控",
"customer_scale": "客户规模", "customer_concentration": "客户集中度", "customer_satisfaction": "客户满意",
"supply_chain": "供应链效率", "delivery_quality": "交付质量",
"talent_pipeline": "人才梯队", "employee_engagement": "员工敬业", "innovation": "创新改善",
}
tree = []
for dim, cat, cnt in rows:
# 找或创建维度节点
dim_node = next((n for n in tree if n["key"] == dim), None)
if not dim_node:
dim_node = {"key": dim, "label": dim_map.get(dim, dim), "children": []}
tree.append(dim_node)
dim_node["children"].append({
"key": cat,
"label": cat_map.get(cat, cat),
"count": cnt,
})
dim_counts = {}
for d in tree:
dim_counts[d["key"]] = sum(c["count"] for c in d["children"])
d["count"] = dim_counts[d["key"]]
return {"tree": tree, "total": sum(dim_counts.values())}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
return kpi_to_dict(kpi)
@router.post("")
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
kpi = KPIDefinition(**data)
db.add(kpi)
db.commit()
db.refresh(kpi)
_log(db, 1, "create", "kpi", kpi.id, data)
return kpi_to_dict(kpi)
@router.put("/{kpi_id}")
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
for k, v in data.items():
if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v)
db.commit()
_log(db, 1, "update", "kpi", kpi_id, data)
return kpi_to_dict(kpi)
@router.delete("/{kpi_id}")
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
kpi.status = "disabled"
db.commit()
return {"message": "已删除"}
def kpi_to_dict(k):
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail):
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
"""查看KPI所属的目标和战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 通过 kpi_definitions.objective 字段关联目标
# 也通过 map_id 关联地图
result = {
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name},
"objectives": [],
"map": None,
}
if kpi.map_id:
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective:
objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id,
MapObjective.name == kpi.objective,
).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result
+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,
}
+113
View File
@@ -0,0 +1,113 @@
"""通知渠道配置 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role
from datetime import datetime
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"])
def ch_to_dict(c):
return {
"id": c.id,
"name": c.name,
"channel_type": c.channel_type,
"config": c.config,
"enabled": c.enabled,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
@router.get("/channels")
def list_channels(db: Session = Depends(get_db)):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(NotificationChannel.id).all()
return {"data": [ch_to_dict(c) for c in channels]}
@router.post("/channels")
def create_channel(data: dict, db: Session = Depends(get_db)):
"""创建通知渠道"""
ch = NotificationChannel(
name=data["name"],
channel_type=data["channel_type"],
config=data.get("config", {}),
enabled=data.get("enabled", True),
)
db.add(ch)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.put("/channels/{ch_id}")
def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
"""更新通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
for k, v in data.items():
if hasattr(ch, k) and k not in ("id", "created_at"):
setattr(ch, k, v)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.delete("/channels/{ch_id}")
def delete_channel(ch_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
db.delete(ch)
db.commit()
return {"message": "已删除"}
@router.post("/channels/{ch_id}/test")
def test_channel(ch_id: int, db: Session = Depends(get_db)):
"""测试推送"""
from app.utils.notifier import push_alert
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
config = ch.config or {}
test_alert = {
"alert_level": "yellow",
"alert_message": "【测试通知】这是一条管理会计OS的测试预警",
"kpi_name": "销售总额",
"period": datetime.now().strftime("%Y-%m"),
"actual_value": "800,000",
"target_value": "1,000,000",
}
results = push_alert(test_alert, [{
"name": ch.name, "channel_type": ch.channel_type,
"config": config, "enabled": True
}])
return {"results": results}
@router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"data": [{
"id": l.id,
"alert_id": l.alert_id,
"channel": l.channel,
"recipient": l.recipient,
"title": l.title,
"status": l.status,
"error_msg": l.error_msg,
"sent_at": l.sent_at.isoformat() if l.sent_at else None,
"created_at": l.created_at.isoformat() if l.created_at else None,
} for l in logs]
}
+83
View File
@@ -0,0 +1,83 @@
"""战略地图目标 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_role
from app.models import MapObjective, StrategicMap, KPIDefinition
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
@router.get("/{map_id}/objectives")
def list_objectives(map_id: int, db: Session = Depends(get_db)):
"""获取某地图下的所有目标"""
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id
).order_by(MapObjective.sort_order).all()
return {"data": [_obj_to_dict(o) for o in objs]}
@router.post("/{map_id}/objectives")
def create_objective(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, "战略地图不存在")
obj = MapObjective(
map_id=map_id,
dimension_key=data["dimension_key"],
name=data["name"],
description=data.get("description"),
icon=data.get("icon", "target"),
sort_order=data.get("sort_order", 0),
)
db.add(obj)
db.commit()
db.refresh(obj)
return _obj_to_dict(obj)
@router.put("/{map_id}/objectives/{obj_id}")
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
"""修改目标"""
obj = db.query(MapObjective).filter(
MapObjective.id == obj_id, MapObjective.map_id == map_id
).first()
if not obj:
raise HTTPException(404, "目标不存在")
for k, v in data.items():
if hasattr(obj, k) and v is not None:
setattr(obj, k, v)
db.commit()
return _obj_to_dict(obj)
@router.delete("/{map_id}/objectives/{obj_id}")
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
"""删除目标"""
obj = db.query(MapObjective).filter(
MapObjective.id == obj_id, MapObjective.map_id == map_id
).first()
if not obj:
raise HTTPException(404, "目标不存在")
db.delete(obj)
db.commit()
return {"message": "已删除"}
@router.put("/{map_id}/objectives/sort")
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
"""批量排序: {"ids": [3, 1, 2]}"""
ids = data.get("ids", [])
for idx, obj_id in enumerate(ids):
db.query(MapObjective).filter(
MapObjective.id == obj_id, MapObjective.map_id == map_id
).update({"sort_order": idx})
db.commit()
return {"message": "排序已更新"}
def _obj_to_dict(o):
return {c.name: getattr(o, c.name) for c in o.__table__.columns}
+101
View File
@@ -0,0 +1,101 @@
"""组织层级 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db, init_db
from app.auth_middleware import require_auth, require_role
from app.models import OrgNode, User
router = APIRouter(prefix="/api/cma/org", tags=["组织管理"],
dependencies=[Depends(require_role("ceo", "it"))],
)
@router.get("/tree")
def get_org_tree(db: Session = Depends(get_db)):
"""返回全量树结构: [{id, label, children}]"""
nodes = db.query(OrgNode).order_by(OrgNode.sort_order).all()
return {"data": _build_tree(nodes)}
@router.get("/nodes")
def list_org_nodes(db: Session = Depends(get_db)):
"""平铺列表"""
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
return {"data": [_node_to_dict(n) for n in nodes]}
@router.post("/nodes")
def create_org_node(data: dict, db: Session = Depends(get_db)):
"""新增节点"""
node = OrgNode(
parent_id=data.get("parent_id"),
name=data["name"],
code=data.get("code"),
level=data["level"],
sort_order=data.get("sort_order", 0),
enabled=data.get("enabled", 1),
remark=data.get("remark"),
)
db.add(node)
db.commit()
db.refresh(node)
return _node_to_dict(node)
@router.put("/nodes/{node_id}")
def update_org_node(node_id: int, data: dict, db: Session = Depends(get_db)):
"""修改节点"""
node = db.query(OrgNode).filter(OrgNode.id == node_id).first()
if not node:
raise HTTPException(404, "节点不存在")
for k, v in data.items():
if hasattr(node, k) and v is not None:
setattr(node, k, v)
db.commit()
return _node_to_dict(node)
@router.delete("/nodes/{node_id}")
def delete_org_node(node_id: int, db: Session = Depends(get_db)):
"""删除节点(有子节点则阻止)"""
node = db.query(OrgNode).filter(OrgNode.id == node_id).first()
if not node:
raise HTTPException(404, "节点不存在")
# 检查是否有子节点
children = db.query(OrgNode).filter(OrgNode.parent_id == node_id).count()
if children > 0:
raise HTTPException(400, f"该节点有 {children} 个子节点,请先删除子节点")
db.delete(node)
db.commit()
return {"message": "已删除"}
@router.put("/nodes/{node_id}/toggle")
def toggle_org_node(node_id: int, db: Session = Depends(get_db)):
"""切换启用/禁用"""
node = db.query(OrgNode).filter(OrgNode.id == node_id).first()
if not node:
raise HTTPException(404, "节点不存在")
node.enabled = 0 if node.enabled else 1
db.commit()
return _node_to_dict(node)
# ── 工具 ─────────────────────────────────
def _build_tree(nodes: list) -> list:
"""将平铺节点列表转为树结构"""
node_map = {n.id: {"id": n.id, "label": n.name, "level": n.level, "enabled": bool(n.enabled), "code": n.code, "children": []} for n in nodes}
tree = []
for n in nodes:
item = node_map[n.id]
if n.parent_id and n.parent_id in node_map:
node_map[n.parent_id]["children"].append(item)
else:
tree.append(item)
return tree
def _node_to_dict(n):
return {c.name: getattr(n, c.name) for c in n.__table__.columns}
+121
View File
@@ -0,0 +1,121 @@
"""
角色权限管理 API — 管理会计OS
支持在页面上配置角色可访问的模块和操作权限
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import RolePermission
from app.auth_middleware import require_auth, require_role
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"])
# 模块定义(所有可配置的模块)
MODULES = [
{"key": "dashboard", "name": "驾驶舱"},
{"key": "kpis", "name": "KPI字典"},
{"key": "kpi_detail", "name": "KPI详情"},
{"key": "maps", "name": "战略地图"},
{"key": "alerts", "name": "预警中心"},
{"key": "ai_analysis", "name": "AI分析"},
{"key": "data_source", "name": "数据管理"},
{"key": "budget", "name": "预算管理"},
{"key": "deviation", "name": "差异分析"},
{"key": "cost", "name": "成本分析"},
{"key": "predict", "name": "预测模拟"},
{"key": "org", "name": "组织管理"},
{"key": "user_manage", "name": "用户管理"},
{"key": "system_config", "name": "通知配置"},
{"key": "role_permissions", "name": "角色权限"},
{"key": "action_plans", "name": "改善行动"},
{"key": "alignment", "name": "KPI目标对齐"},
]
ACTIONS = [
{"key": "read", "name": "读取"},
{"key": "write", "name": "写入"},
{"key": "import", "name": "导入"},
{"key": "export", "name": "导出"},
{"key": "delete", "name": "删除"},
{"key": "approve", "name": "审批"},
{"key": "admin", "name": "管理"},
]
ROLES = [
{"code": "ceo", "name": "CEO"},
{"code": "finance", "name": "财务"},
{"code": "business", "name": "业务"},
{"code": "it", "name": "IT运维"},
]
# 默认权限
DEFAULT_ROUTE_PERMISSIONS = {
"ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "budget", "deviation", "cost", "predict", "org", "user_manage", "system_config", "role_permissions", "action_plans", "alignment"],
"finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "budget", "deviation", "cost", "predict"],
"business": ["dashboard", "kpis", "kpi_detail", "alerts", "budget", "deviation"],
"it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "budget", "deviation", "cost", "predict", "org", "user_manage", "system_config"],
}
DEFAULT_ACTION_PERMISSIONS = {
"ceo": ["read", "approve"],
"finance": ["read", "write", "import", "export"],
"business": ["read", "write"],
"it": ["read", "write", "delete", "admin"],
}
def _get_or_create_defaults(db: Session):
"""获取配置,不存在则创建默认值"""
route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first()
if not route_perm:
route_perm = RolePermission(key="route_permissions", value=DEFAULT_ROUTE_PERMISSIONS)
db.add(route_perm)
action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first()
if not action_perm:
action_perm = RolePermission(key="action_permissions", value=DEFAULT_ACTION_PERMISSIONS)
db.add(action_perm)
db.commit()
db.refresh(route_perm)
db.refresh(action_perm)
return route_perm, action_perm
@router.get("/modules")
def list_modules():
"""返回模块和动作定义"""
return {
"modules": MODULES,
"actions": ACTIONS,
"roles": ROLES,
}
@router.get("/config")
def get_permissions(db: Session = Depends(get_db)):
"""获取当前权限配置"""
route_perm, action_perm = _get_or_create_defaults(db)
return {
"route_permissions": route_perm.value,
"action_permissions": action_perm.value,
}
@router.put("/config")
def update_permissions(
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "it")),
):
"""更新权限配置"""
route_perm, action_perm = _get_or_create_defaults(db)
if "route_permissions" in data:
route_perm.value = data["route_permissions"]
if "action_permissions" in data:
action_perm.value = data["action_permissions"]
db.commit()
return {"message": "权限配置已更新"}
+98
View File
@@ -0,0 +1,98 @@
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException
from app.utils.predict_engine import (
cvp_analysis, npv, irr,
sensitivity_analysis, scenario_analysis,
)
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
@router.post("/cvp")
def api_cvp_analysis(data: dict):
"""CVP本量利分析"""
try:
result = cvp_analysis(
unit_price=float(data.get("unit_price", 0)),
unit_variable_cost=float(data.get("unit_variable_cost", 0)),
fixed_cost=float(data.get("fixed_cost", 0)),
target_profit=float(data["target_profit"]) if data.get("target_profit") else None,
actual_volume=float(data["actual_volume"]) if data.get("actual_volume") else None,
)
return result
except Exception as e:
raise HTTPException(400, f"CVP计算失败: {str(e)}")
@router.post("/investment")
def api_investment_analysis(data: dict):
"""投资决策分析(NPV/IRR/回收期)"""
try:
initial = float(data.get("initial_investment", 0))
rate = float(data.get("discount_rate", 10))
cash_flows = [float(cf) for cf in data.get("cash_flows", [])]
if not cash_flows:
raise HTTPException(400, "现金流列表不能为空")
npv_result = npv(initial, cash_flows, rate)
irr_result = irr(initial, cash_flows)
return {
"npv_analysis": npv_result,
"irr_analysis": irr_result,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"投资决策计算失败: {str(e)}")
@router.post("/sensitivity")
def api_sensitivity_analysis(data: dict):
"""敏感性分析"""
try:
result = sensitivity_analysis(
base_revenue=float(data.get("base_revenue", 0)),
base_cost=float(data.get("base_cost", 0)),
base_profit=float(data["base_profit"]) if data.get("base_profit") else None,
step=int(data.get("step", 5)),
max_step=int(data.get("max_step", 20)),
)
return result
except Exception as e:
raise HTTPException(400, f"敏感性分析失败: {str(e)}")
@router.post("/scenario")
def api_scenario_analysis(data: dict):
"""情景模拟"""
try:
optimistic = data.get("optimistic", {})
pessimistic = data.get("pessimistic", {})
base = data.get("base", {})
if not all([optimistic, pessimistic, base]):
raise HTTPException(400, "需要提供乐观/中性/悲观三个情景的参数")
result = scenario_analysis(
optimistic={
"revenue": float(optimistic.get("revenue", 0)),
"cost": float(optimistic.get("cost", 0)),
},
pessimistic={
"revenue": float(pessimistic.get("revenue", 0)),
"cost": float(pessimistic.get("cost", 0)),
},
base={
"revenue": float(base.get("revenue", 0)),
"cost": float(base.get("cost", 0)),
},
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"情景模拟失败: {str(e)}")
+97
View File
@@ -0,0 +1,97 @@
"""阈值智能推荐 API"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, OperationLog
import json
router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
KPI_TYPES = {
"higher_better": ["SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE", "TRAINING_COMPLETION",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE"],
"lower_better": ["COST_CONTROL_RATE"],
"middle_best": ["TOP5_CUSTOMER_RATIO"],
}
@router.get("/suggest/{kpi_id}")
def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
"""根据历史数据自动推荐阈值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return {"error": "KPI不存在"}
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
if not values:
# 无历史数据,按行业标准推荐
return suggest_by_type(kpi)
nums = [v.actual_value for v in values if v.actual_value is not None]
if len(nums) < 2:
return suggest_by_type(kpi)
avg = sum(nums) / len(nums)
# 计算标准差
variance = sum((x - avg) ** 2 for x in nums) / len(nums)
std = variance ** 0.5
target = kpi.target_value or avg
# 根据KPI类型生成推荐区间
if kpi.kpi_code in KPI_TYPES["higher_better"]:
green_min = round(target * 0.8, 2)
yellow_min = round(target * 0.5, 2)
red_max = round(target * 0.5, 2)
suggestion = {
"type": "higher_better",
"description": "越高越好型",
"green": {"min": green_min, "max": None, "label": f">={green_min}"},
"yellow": {"min": yellow_min, "max": green_min, "label": f"{yellow_min}~{green_min}"},
"red": {"min": None, "max": red_max, "label": f"<{red_max}"},
"current_avg": round(avg, 2),
"target": target,
}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
green_max = round(target * 1.2, 2)
yellow_max = round(target * 2.0, 2)
suggestion = {
"type": "lower_better",
"description": "越低越好型",
"green": {"min": None, "max": green_max, "label": f"<={green_max}"},
"yellow": {"min": green_max, "max": yellow_max, "label": f"{green_max}~{yellow_max}"},
"red": {"min": yellow_max, "max": None, "label": f">{yellow_max}"},
"current_avg": round(avg, 2),
"target": target,
}
else:
tolerance = max(std * 1.5, target * 0.2)
suggestion = {
"type": "middle_best",
"description": "适中最好型",
"green": {"min": round(target - tolerance, 2), "max": round(target + tolerance, 2), "label": f"{round(target-tolerance,2)}~{round(target+tolerance,2)}"},
"yellow": {"min": round(target - tolerance*2, 2), "max": round(target + tolerance*2, 2), "label": f"偏离{(tolerance*2):.0f}%"},
"red": {"min": None, "max": round(target - tolerance*2, 2), "label": f"偏离>{tolerance*2:.0f}%"},
"current_avg": round(avg, 2),
"target": target,
}
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name, "suggestion": suggestion}
def suggest_by_type(kpi):
"""无历史数据时按类型推荐"""
target = kpi.target_value or 100
if kpi.kpi_code in KPI_TYPES["higher_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "higher_better", "green": {"min": round(target*0.8,2)}, "yellow": {"min": round(target*0.5,2)}, "red": {"max": round(target*0.5,2)}}}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "lower_better", "green": {"max": round(target*1.2,2)}, "yellow": {"max": round(target*2,2)}, "red": {"min": round(target*2,2)}}}
else:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}}
+66
View File
@@ -0,0 +1,66 @@
"""用户管理 API"""
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import User
router = APIRouter(prefix="/api/cma/users", tags=["用户管理"],
dependencies=[Depends(require_role("ceo", "it"))],
)
def user_to_dict(u):
return {
"id": u.id,
"username": u.username,
"name": u.name,
"role": u.role,
"phone": u.phone,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
@router.get("")
def list_users(db: Session = Depends(get_db)):
users = db.query(User).order_by(User.id).all()
return {"data": [user_to_dict(u) for u in users]}
@router.post("")
def create_user(data: dict, db: Session = Depends(get_db)):
exist = db.query(User).filter(User.username == data.get("username")).first()
if exist:
raise HTTPException(400, "用户名已存在")
user = User(
username=data["username"],
password_hash=hashlib.sha256(data["password"].encode()).hexdigest(),
name=data.get("name", data["username"]),
role=data.get("role", "business"),
phone=data.get("phone", ""),
)
db.add(user)
db.commit()
db.refresh(user)
return user_to_dict(user)
@router.put("/{user_id}")
def update_user(user_id: int, data: dict, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "用户不存在")
for k, v in data.items():
if k == "password" and v:
setattr(user, "password_hash", hashlib.sha256(v.encode()).hexdigest())
elif hasattr(user, k) and v is not None and k not in ("id", "username", "created_at"):
setattr(user, k, v)
db.commit()
db.refresh(user)
return user_to_dict(user)
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "用户不存在")
db.delete(user)
db.commit()
return {"message": "已删除"}
+86
View File
@@ -0,0 +1,86 @@
"""战略地图版本管理 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_role
from app.models import StrategicMap, StrategicMapVersion
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
@router.get("/{map_id}/versions")
def list_versions(map_id: int, db: Session = Depends(get_db)):
"""查看版本历史"""
versions = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id
).order_by(StrategicMapVersion.id.desc()).all()
return {"data": [v_to_dict(v) for v in versions]}
@router.post("/{map_id}/versions/snapshot")
def create_snapshot(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, "战略地图不存在")
import json
dims = m.dimensions
canvas = m.canvas_data
if isinstance(dims, str):
dims = json.loads(dims)
if isinstance(canvas, str):
canvas = json.loads(canvas)
# 自动版本号
existing = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id
).order_by(StrategicMapVersion.id.desc()).first()
if existing:
import re
match = re.search(r"v(\d+)\.(\d+)", existing.version)
major = int(match.group(1)) if match else 1
minor = int(match.group(2)) + 1 if match else 0
new_ver = f"v{major}.{minor}"
else:
new_ver = "v1.0"
snapshot = StrategicMapVersion(
map_id=map_id,
version=new_ver,
dimensions=dims,
canvas_data=canvas,
comment=data.get("comment", f"手动快照 {new_ver}"),
)
db.add(snapshot)
db.commit()
db.refresh(snapshot)
return v_to_dict(snapshot)
@router.post("/{map_id}/versions/{ver_id}/rollback")
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
"""回滚到指定版本"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
v = db.query(StrategicMapVersion).filter(
StrategicMapVersion.id == ver_id,
StrategicMapVersion.map_id == map_id,
).first()
if not v:
raise HTTPException(404, "版本不存在")
m.dimensions = v.dimensions
m.canvas_data = v.canvas_data
m.version = f"rollback-{v.version}"
m.status = "draft"
db.commit()
return {"message": f"已回滚到 {v.version}", "version": m.version}
def v_to_dict(v):
return {c.name: getattr(v, c.name) for c in v.__table__.columns}