init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
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.
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.
Binary file not shown.
@@ -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": "已删除"}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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": "正常"}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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. 我的KPI(responsible_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,
|
||||
}
|
||||
@@ -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": "删除成功"}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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": "权限配置已更新"}
|
||||
@@ -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)}")
|
||||
@@ -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)}}}
|
||||
@@ -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": "已删除"}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
角色权限中间件 — 管理会计OS
|
||||
4角色: ceo(CEO/总览), finance(财务), business(业务), it(IT/运维)
|
||||
权限配置支持从数据库动态加载
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, RolePermission
|
||||
import secrets
|
||||
import json
|
||||
|
||||
# 角色定义(固定)
|
||||
ROLES = {
|
||||
"ceo": {"name": "CEO", "priority": 1},
|
||||
"finance": {"name": "财务", "priority": 2},
|
||||
"business": {"name": "业务", "priority": 3},
|
||||
"it": {"name": "IT运维", "priority": 4},
|
||||
}
|
||||
|
||||
# 默认权限(数据库没有时的 fallback)
|
||||
DEFAULT_ROUTE_PERMISSIONS = {
|
||||
"ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "user_manage", "system_config"],
|
||||
"finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source"],
|
||||
"business": ["dashboard", "kpis", "kpi_detail", "alerts"],
|
||||
"it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "user_manage", "system_config"],
|
||||
}
|
||||
|
||||
DEFAULT_KPI_VISIBILITY = {
|
||||
"ceo": ["*"], # CEO看全部维度
|
||||
"finance": ["finance_*"], # 财务只看财务
|
||||
"business": ["customer_*", "process_*", "learning_*"], # 业务看客户/流程/学习
|
||||
"it": ["*"], # IT看全部(运维)
|
||||
}
|
||||
|
||||
DEFAULT_ACTION_PERMISSIONS = {
|
||||
"ceo": ["read", "approve"],
|
||||
"finance": ["read", "write", "import", "export"],
|
||||
"business": ["read", "write"],
|
||||
"it": ["read", "write", "delete", "admin"],
|
||||
}
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("cma.auth")
|
||||
|
||||
# Redis token 存储(跨 worker 共享)
|
||||
try:
|
||||
import redis as redis_lib
|
||||
_redis = redis_lib.Redis(
|
||||
host="127.0.0.1", port=6379, db=1,
|
||||
decode_responses=True, socket_connect_timeout=2, socket_timeout=3
|
||||
)
|
||||
_redis.ping()
|
||||
_redis_available = True
|
||||
except Exception:
|
||||
_redis = None
|
||||
_redis_available = False
|
||||
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
|
||||
|
||||
# 内存 fallback
|
||||
_token_store: dict[str, int] = {}
|
||||
|
||||
TOKEN_PREFIX = "cma:token:"
|
||||
TOKEN_TTL = 86400 # 24小时
|
||||
|
||||
# 缓存权限配置(每5分钟刷新)
|
||||
_permissions_cache = {"route": None, "action": None, "ts": 0}
|
||||
_PERM_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _load_permissions(db: Session = None):
|
||||
"""从数据库加载权限配置"""
|
||||
import time
|
||||
now = time.time()
|
||||
if db is None:
|
||||
if now - _permissions_cache["ts"] < _PERM_CACHE_TTL:
|
||||
return _permissions_cache["route"] or DEFAULT_ROUTE_PERMISSIONS, _permissions_cache["action"] or DEFAULT_ACTION_PERMISSIONS
|
||||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
try:
|
||||
route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first()
|
||||
action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first()
|
||||
|
||||
routes = route_perm.value if route_perm else DEFAULT_ROUTE_PERMISSIONS
|
||||
actions = action_perm.value if action_perm else DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
_permissions_cache["route"] = routes
|
||||
_permissions_cache["action"] = actions
|
||||
_permissions_cache["ts"] = now
|
||||
|
||||
return routes, actions
|
||||
except Exception:
|
||||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
token = secrets.token_hex(32)
|
||||
if _redis_available:
|
||||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id)
|
||||
else:
|
||||
_token_store[token] = user_id
|
||||
return token
|
||||
|
||||
|
||||
def _resolve_user_id(token: str) -> int | None:
|
||||
if _redis_available:
|
||||
val = _redis.get(f"{TOKEN_PREFIX}{token}")
|
||||
if val is not None:
|
||||
return int(val)
|
||||
return None
|
||||
return _token_store.get(token)
|
||||
|
||||
|
||||
def require_auth(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
user_id = _resolve_user_id(token)
|
||||
if user_id is None:
|
||||
raise HTTPException(401, "无效的token,请重新登录")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(401, "用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
async def role_checker(
|
||||
current_user: User = Depends(require_auth),
|
||||
) -> User:
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(403, f"权限不足: 需要 {', '.join(roles)} 角色")
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
def has_permission(user: User, module: str, db: Session = None) -> bool:
|
||||
routes, _ = _load_permissions(db)
|
||||
return module in routes.get(user.role, [])
|
||||
|
||||
|
||||
def has_action(user: User, action: str, db: Session = None) -> bool:
|
||||
_, actions = _load_permissions(db)
|
||||
return action in actions.get(user.role, [])
|
||||
|
||||
|
||||
# ─── KPI可见性(按维度/分类过滤) ───
|
||||
|
||||
def _load_kpi_visibility(db: Session = None):
|
||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
|
||||
import time
|
||||
if not hasattr(_load_kpi_visibility, "_cache"):
|
||||
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||||
cache = _load_kpi_visibility._cache
|
||||
now = time.time()
|
||||
if db is None or (cache["data"] and now - cache["ts"] < _PERM_CACHE_TTL):
|
||||
return cache["data"] or DEFAULT_KPI_VISIBILITY
|
||||
try:
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == "kpi_visibility").first()
|
||||
cache["data"] = perm.value if perm else DEFAULT_KPI_VISIBILITY
|
||||
cache["ts"] = now
|
||||
return cache["data"]
|
||||
except Exception:
|
||||
return DEFAULT_KPI_VISIBILITY
|
||||
|
||||
|
||||
def kpi_visible_dims(role: str, db: Session = None) -> list[str]:
|
||||
"""返回角色可见的维度列表(空列表=全部可见)"""
|
||||
vis = _load_kpi_visibility(db)
|
||||
rules = vis.get(role, ["*"])
|
||||
if "*" in rules:
|
||||
return [] # 空=全部可见
|
||||
# 提取维度前缀:finance_* -> finance
|
||||
dims = set()
|
||||
for r in rules:
|
||||
if r.endswith("_*"):
|
||||
dims.add(r[:-2])
|
||||
return list(dims)
|
||||
|
||||
|
||||
def filter_kpis_by_role(kpis: list, role: str, db: Session = None) -> list:
|
||||
"""按角色可见性过滤KPI列表"""
|
||||
dims = kpi_visible_dims(role, db)
|
||||
if not dims:
|
||||
return kpis # 全部可见
|
||||
return [k for k in kpis if k.dimension in dims or (hasattr(k, 'dimension') and k.dimension in dims)]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from sqlalchemy import inspect
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma")
|
||||
|
||||
DB_USER = os.getenv("CMA_DB_USER", "cma_user")
|
||||
DB_PASS = os.getenv("CMA_DB_PASS", "cma_pass_2026")
|
||||
DB_HOST = os.getenv("CMA_DB_HOST", "127.0.0.1")
|
||||
DB_PORT = os.getenv("CMA_DB_PORT", "3306")
|
||||
DB_NAME = os.getenv("CMA_DB_NAME", "cma")
|
||||
|
||||
DATABASE_URL = "mysql+pymysql://%(user)s:%(password)s@%(host)s:%(port)s/%(name)s?charset=utf8mb4" % {
|
||||
"user": DB_USER,
|
||||
"password": DB_PASS,
|
||||
"host": DB_HOST,
|
||||
"port": DB_PORT,
|
||||
"name": DB_NAME,
|
||||
}
|
||||
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(DATABASE_URL, echo=False, pool_size=5, max_overflow=10, pool_pre_ping=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_local():
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_db():
|
||||
db = get_session_local()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
import app.models
|
||||
Base.metadata.create_all(bind=get_engine())
|
||||
logger.info("CMA数据库已初始化")
|
||||
|
||||
# ── 初始化组织层级示例数据 ──
|
||||
try:
|
||||
inspector = inspect(get_engine())
|
||||
if "org_nodes" in inspector.get_table_names():
|
||||
Session = get_session_local()
|
||||
session = Session()
|
||||
try:
|
||||
cnt = session.query(app.models.OrgNode).count()
|
||||
if cnt == 0:
|
||||
_seed_org_data(session)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"组织数据初始化跳过: {e}")
|
||||
|
||||
|
||||
def _seed_org_data(db_session):
|
||||
"""插入5层级组织示例数据"""
|
||||
from app.models import OrgNode
|
||||
|
||||
# 1. 集团
|
||||
g = OrgNode(id=1, parent_id=None, name="博海网络科技", code="BH", level=1, sort_order=1, enabled=1)
|
||||
db_session.add(g)
|
||||
db_session.flush()
|
||||
|
||||
# 2. 事业部
|
||||
depts = [
|
||||
OrgNode(parent_id=1, name="技术事业部", code="TECH", level=2, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=1, name="销售事业部", code="SALES", level=2, sort_order=2, enabled=1),
|
||||
OrgNode(parent_id=1, name="财务事业部", code="FIN", level=2, sort_order=3, enabled=1),
|
||||
]
|
||||
db_session.add_all(depts)
|
||||
db_session.flush()
|
||||
|
||||
# 3. 区域/部门级
|
||||
regions = [
|
||||
OrgNode(parent_id=2, name="华南区域", code="SC", level=3, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=2, name="华东区域", code="EC", level=3, sort_order=2, enabled=1),
|
||||
OrgNode(parent_id=3, name="销售一部", code="S1", level=3, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=3, name="销售二部", code="S2", level=3, sort_order=2, enabled=1),
|
||||
]
|
||||
db_session.add_all(regions)
|
||||
db_session.flush()
|
||||
|
||||
# 4. 部门
|
||||
departs = [
|
||||
OrgNode(parent_id=5, name="研发部", code="RD", level=4, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=5, name="实施部", code="IMP", level=4, sort_order=2, enabled=1),
|
||||
OrgNode(parent_id=5, name="运维部", code="OPS", level=4, sort_order=3, enabled=1),
|
||||
OrgNode(parent_id=6, name="前端研发", code="FE", level=4, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=6, name="后端研发", code="BE", level=4, sort_order=2, enabled=1),
|
||||
OrgNode(parent_id=8, name="KA客户部", code="KA", level=4, sort_order=1, enabled=1),
|
||||
]
|
||||
db_session.add_all(departs)
|
||||
db_session.flush()
|
||||
|
||||
# 5. 班组
|
||||
teams = [
|
||||
OrgNode(parent_id=12, name="前端组", code="FE-TEAM", level=5, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=12, name="后端组", code="BE-TEAM", level=5, sort_order=2, enabled=1),
|
||||
OrgNode(parent_id=12, name="测试组", code="QA-TEAM", level=5, sort_order=3, enabled=1),
|
||||
OrgNode(parent_id=13, name="实施一组", code="IMP1", level=5, sort_order=1, enabled=1),
|
||||
OrgNode(parent_id=13, name="实施二组", code="IMP2", level=5, sort_order=2, enabled=1),
|
||||
]
|
||||
db_session.add_all(teams)
|
||||
db_session.commit()
|
||||
logger.info("组织层级示例数据已初始化")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""管理会计OS — 主入口"""
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 日志配置
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("cma")
|
||||
|
||||
app = FastAPI(title="管理会计OS API", version="1.0.0", docs_url="/docs")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(kpis.router)
|
||||
app.include_router(maps.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(ai_analysis.router)
|
||||
app.include_router(alert_rules.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(thresholds.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(permissions.router)
|
||||
app.include_router(action_plans.router)
|
||||
app.include_router(alignment.router)
|
||||
app.include_router(org.router)
|
||||
app.include_router(objectives.router)
|
||||
app.include_router(versions.router)
|
||||
app.include_router(budget.router)
|
||||
app.include_router(cost.router)
|
||||
app.include_router(predict.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"未捕获异常: {exc}", exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": "服务器内部错误"})
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
init_db()
|
||||
logger.info("管理会计OS后端启动完成")
|
||||
|
||||
|
||||
@app.post("/api/cma/admin/erp-sync")
|
||||
def admin_erp_sync(kpi_codes: str = None):
|
||||
"""手动触发ERP数据同步"""
|
||||
kpi_list = kpi_codes.split(",") if kpi_codes else None
|
||||
try:
|
||||
run_erp_sync(dry_run=False, kpi_codes=kpi_list, use_api=True)
|
||||
return {"message": "ERP同步完成", "kpis": kpi_list}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"detail": f"同步失败: {str(e)}"})
|
||||
|
||||
|
||||
@app.get("/api/cma/admin/erp-sync/dry-run")
|
||||
def admin_erp_sync_dry_run(kpi_codes: str = None):
|
||||
"试运行,不写入数据库"""
|
||||
kpi_list = kpi_codes.split(",") if kpi_codes else None
|
||||
try:
|
||||
run_erp_sync(dry_run=True, kpi_codes=kpi_list, use_api=True)
|
||||
return {"message": "试运行完成"}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"detail": f"试运行失败: {str(e)}"})
|
||||
|
||||
|
||||
@app.post("/api/cma/admin/alerts/check")
|
||||
def admin_check_alerts():
|
||||
"""手动触发预警检查"""
|
||||
from app.database import get_session_local
|
||||
from scripts.alert_generator import generate_and_push
|
||||
db = get_session_local()()
|
||||
try:
|
||||
result = generate_and_push(db)
|
||||
return {"message": "预警检查完成", "result": result}
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"detail": f"检查失败: {str(e)}"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/cma/admin/cache/clear")
|
||||
def admin_clear_cache(module: str = None):
|
||||
"""清空缓存,指定module则只清该模块"""
|
||||
if module:
|
||||
delete_cache(module)
|
||||
return {"message": f"缓存已清空: {module}"}
|
||||
else:
|
||||
clear_cache()
|
||||
return {"message": "全部缓存已清空"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "version": "1.0.0"}
|
||||
@@ -0,0 +1,214 @@
|
||||
"""管理会计OS 数据模型"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func
|
||||
from app.database import Base
|
||||
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户"""
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
password_hash = Column(String(128), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
role = Column(String(20), default="finance") # ceo / finance / business / it
|
||||
phone = Column(String(20), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class StrategicMap(Base):
|
||||
"""战略地图"""
|
||||
__tablename__ = "strategic_maps"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False, comment="地图名称")
|
||||
version = Column(String(20), default="v1.0", comment="版本号")
|
||||
status = Column(String(20), default="draft", comment="draft/published")
|
||||
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
|
||||
canvas_data = Column(JSON, nullable=True, comment="画布连线数据")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class KPIDefinition(Base):
|
||||
"""KPI字典"""
|
||||
__tablename__ = "kpi_definitions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
||||
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
|
||||
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
|
||||
dimension = Column(String(50), comment="所属维度: finance/customer/process/learning")
|
||||
objective = Column(String(200), comment="关联战略目标")
|
||||
formula = Column(Text, nullable=True, comment="计算公式")
|
||||
formula_desc = Column(String(500), nullable=True, comment="公式说明")
|
||||
data_source_type = Column(String(20), default="manual", comment="erp/business/excel/manual")
|
||||
data_source_config = Column(JSON, nullable=True, comment="数据源配置")
|
||||
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
|
||||
unit = Column(String(50), default="%", comment="单位")
|
||||
target_value = Column(Float, nullable=True, comment="目标值")
|
||||
threshold_green = Column(String(100), nullable=True, comment="绿灯阈值")
|
||||
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
||||
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
||||
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||
status = Column(String(20), default="active")
|
||||
epic = Column(String(50), default="Epic2", comment="所属Epic")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class KPIValue(Base):
|
||||
"""KPI实际值"""
|
||||
__tablename__ = "kpi_values"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||
source_type = Column(String(20), default="manual", comment="erp/excel/manual")
|
||||
source_batch = Column(String(100), nullable=True, comment="导入批次号")
|
||||
data_status = Column(String(20), default="pending", comment="pending/verified/error")
|
||||
calculated_at = Column(DateTime, server_default=func.now())
|
||||
remark = Column(String(500), nullable=True)
|
||||
|
||||
|
||||
class DataSourceConfig(Base):
|
||||
"""数据源配置"""
|
||||
__tablename__ = "data_source_config"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, comment="数据源名称")
|
||||
source_type = Column(String(20), nullable=False, comment="erp/business/excel")
|
||||
api_endpoint = Column(String(500), nullable=True, comment="API地址")
|
||||
api_key = Column(String(200), nullable=True, comment="API Key")
|
||||
query_sql = Column(Text, nullable=True, comment="SQL查询语句")
|
||||
sync_type = Column(String(20), default="realtime", comment="realtime/batch")
|
||||
status = Column(String(20), default="active")
|
||||
last_sync_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class KPIAlert(Base):
|
||||
"""预警记录"""
|
||||
__tablename__ = "kpi_alerts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
|
||||
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
|
||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||
alert_message = Column(String(500), nullable=False)
|
||||
status = Column(String(20), default="pending", comment="pending/processing/resolved")
|
||||
assignee = Column(String(100), nullable=True, comment="处理人")
|
||||
resolution = Column(Text, nullable=True, comment="处理结果")
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class OperationLog(Base):
|
||||
"""操作日志"""
|
||||
__tablename__ = "operation_logs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=True)
|
||||
action = Column(String(50), nullable=False, comment="create/update/delete/calculate/import")
|
||||
target_type = Column(String(50), nullable=False, comment="kpi/map/alert/source")
|
||||
target_id = Column(Integer, nullable=True)
|
||||
detail = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
"""通知渠道配置"""
|
||||
__tablename__ = "notification_channels"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False, comment="渠道名称")
|
||||
channel_type = Column(String(30), nullable=False, comment="wecom/mail/sms")
|
||||
config = Column(JSON, nullable=True, comment="渠道配置")
|
||||
enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
"""通知发送日志"""
|
||||
__tablename__ = "notification_logs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True)
|
||||
channel = Column(String(30), nullable=False, comment="wecom/mail")
|
||||
recipient = Column(String(200), nullable=True, comment="收件人")
|
||||
title = Column(String(200), nullable=True)
|
||||
content = Column(Text, nullable=True)
|
||||
status = Column(String(20), default="pending", comment="pending/sent/failed")
|
||||
error_msg = Column(String(500), nullable=True)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限配置(单条记录,key-value)"""
|
||||
__tablename__ = "role_permissions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(50), unique=True, nullable=False, comment="配置键: route_permissions / action_permissions")
|
||||
value = Column(JSON, nullable=False, comment="配置值")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ActionPlan(Base):
|
||||
"""改善行动计划"""
|
||||
__tablename__ = "action_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
title = Column(String(200), nullable=False, comment="计划标题")
|
||||
description = Column(Text, nullable=True, comment="详细描述")
|
||||
assignee = Column(String(100), nullable=True, comment="负责人")
|
||||
priority = Column(String(20), default="medium", comment="high/medium/low")
|
||||
due_date = Column(DateTime, nullable=True, comment="截止日期")
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
|
||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||
result = Column(Text, nullable=True, comment="改善结果")
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class OrgNode(Base):
|
||||
"""组织节点: 集团→事业部→区域→部门→班组 5级"""
|
||||
__tablename__ = "org_nodes"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
parent_id = Column(Integer, ForeignKey("org_nodes.id"), nullable=True, comment="父节点ID")
|
||||
name = Column(String(100), nullable=False, comment="节点名称")
|
||||
code = Column(String(50), unique=True, nullable=True, comment="编码")
|
||||
level = Column(Integer, nullable=False, comment="1=集团 2=事业部 3=区域 4=部门 5=班组")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
enabled = Column(Integer, default=1, comment="1启用 0禁用")
|
||||
path = Column(String(500), nullable=True, comment="路径")
|
||||
remark = Column(String(200), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class StrategicMapVersion(Base):
|
||||
"""战略地图版本快照"""
|
||||
__tablename__ = "strategic_map_versions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
|
||||
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
|
||||
canvas_data = Column(JSON, nullable=False, comment="画布数据快照")
|
||||
comment = Column(String(500), nullable=True, comment="说明")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class MapObjective(Base):
|
||||
"""战略地图目标: 每个维度下的具体目标"""
|
||||
__tablename__ = "map_objectives"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
|
||||
name = Column(String(200), nullable=False, comment="目标名称")
|
||||
description = Column(Text, nullable=True, comment="描述")
|
||||
icon = Column(String(50), default="target", comment="图标标识")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
"""预算计划模型 — 管理会计OS"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BudgetPlan(Base):
|
||||
"""预算计划 — 按KPI按月分解的目标值"""
|
||||
__tablename__ = "budget_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
|
||||
budget_value = Column(Float, nullable=False, comment="预算值")
|
||||
budget_year = Column(Integer, nullable=False, comment="预算年份")
|
||||
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
|
||||
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
|
||||
status = Column(String(20), default="active", comment="active/archived")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_by = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,74 @@
|
||||
"""成本分析模型 — 管理会计OS
|
||||
标准成本卡片、实际成本归集、作业成本法(ABC)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, JSON, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class StandardCost(Base):
|
||||
"""标准成本卡片 — 每项产品或服务的标准成本构成"""
|
||||
__tablename__ = "standard_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead")
|
||||
item_name = Column(String(200), nullable=False, comment="成本项目名称")
|
||||
standard_quantity = Column(Float, nullable=False, comment="标准用量")
|
||||
unit = Column(String(20), nullable=True, comment="单位")
|
||||
standard_price = Column(Float, nullable=False, comment="标准单价")
|
||||
standard_cost = Column(Float, nullable=False, comment="标准成本 = 用量×单价")
|
||||
version = Column(String(20), default="v1.0", comment="版本号")
|
||||
status = Column(String(20), default="active", comment="active/archived")
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ActualCost(Base):
|
||||
"""实际成本归集 — 从ERP/手工录入的实际成本"""
|
||||
__tablename__ = "actual_costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead")
|
||||
item_name = Column(String(200), nullable=False, comment="成本项目名称")
|
||||
actual_quantity = Column(Float, nullable=False, comment="实际用量")
|
||||
actual_price = Column(Float, nullable=False, comment="实际单价")
|
||||
actual_cost = Column(Float, nullable=False, comment="实际成本 = 用量×单价")
|
||||
source = Column(String(50), default="manual", comment="数据来源: erp/manual")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class AbcActivity(Base):
|
||||
"""ABC作业中心定义"""
|
||||
__tablename__ = "abc_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
activity_code = Column(String(50), unique=True, nullable=False, comment="作业编码")
|
||||
activity_name = Column(String(200), nullable=False, comment="作业名称")
|
||||
activity_desc = Column(Text, nullable=True, comment="作业描述")
|
||||
cost_driver = Column(String(100), nullable=False, comment="成本动因")
|
||||
driver_unit = Column(String(50), nullable=True, comment="动因单位")
|
||||
total_cost = Column(Float, default=0, comment="作业总成本")
|
||||
driver_volume = Column(Float, default=0, comment="动因总量")
|
||||
driver_rate = Column(Float, default=0, comment="动因分配率 = 总成本/动因总量")
|
||||
status = Column(String(20), default="active", comment="active/inactive")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AbcAllocation(Base):
|
||||
"""ABC成本分配记录 — 按动因分配到产品"""
|
||||
__tablename__ = "abc_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
period = Column(String(20), nullable=False, comment="期间 2026-05")
|
||||
activity_id = Column(Integer, ForeignKey("abc_activities.id"), nullable=False)
|
||||
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
|
||||
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
|
||||
driver_consumed = Column(Float, nullable=False, comment="消耗的动因量")
|
||||
allocated_cost = Column(Float, nullable=False, comment="分配的成本")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
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.
@@ -0,0 +1,100 @@
|
||||
"""Redis 缓存工具类 — 管理会计OS"""
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("cma.cache")
|
||||
|
||||
try:
|
||||
import redis as redis_lib
|
||||
_client = redis_lib.Redis(
|
||||
host="127.0.0.1",
|
||||
port=6379,
|
||||
db=1,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=3,
|
||||
)
|
||||
_client.ping()
|
||||
_available = True
|
||||
logger.info("Redis 缓存已连接 (db=1)")
|
||||
except Exception as e:
|
||||
_client = None
|
||||
_available = False
|
||||
logger.warning(f"Redis 不可用,回退到无缓存模式: {e}")
|
||||
|
||||
|
||||
def _make_key(module: str, key: str) -> str:
|
||||
"""生成统一格式的缓存key: cma:cache:{module}:{hash}"""
|
||||
h = hashlib.md5(key.encode()).hexdigest()[:16]
|
||||
return f"cma:cache:{module}:{h}"
|
||||
|
||||
|
||||
def get(module: str, key: str) -> Optional[Any]:
|
||||
"""获取缓存"""
|
||||
if not _available:
|
||||
return None
|
||||
try:
|
||||
full_key = _make_key(module, key)
|
||||
data = _client.get(full_key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存读取失败 [{module}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def set(module: str, key: str, value: Any, ttl_seconds: int = 300) -> bool:
|
||||
"""写入缓存,默认5分钟"""
|
||||
if not _available:
|
||||
return False
|
||||
try:
|
||||
full_key = _make_key(module, key)
|
||||
_client.setex(full_key, ttl_seconds, json.dumps(value, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存写入失败 [{module}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete(module: str, key: str = None) -> bool:
|
||||
"""删除缓存。不传key则清空该模块所有缓存"""
|
||||
if not _available:
|
||||
return False
|
||||
try:
|
||||
if key:
|
||||
full_key = _make_key(module, key)
|
||||
_client.delete(full_key)
|
||||
else:
|
||||
pattern = f"cma:cache:{module}:*"
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = _client.scan(cursor=cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
_client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存删除失败 [{module}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def clear_all() -> bool:
|
||||
"""清空所有CMA缓存"""
|
||||
if not _available:
|
||||
return False
|
||||
try:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = _client.scan(cursor=cursor, match="cma:cache:*", count=200)
|
||||
if keys:
|
||||
_client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存清空失败: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,93 @@
|
||||
"""KPI计算引擎 v4 — 基于会计科目余额和销售报表"""
|
||||
import httpx, asyncio
|
||||
from datetime import datetime
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
|
||||
ERP_API = "http://127.0.0.1:8300"
|
||||
ERP_KEY = "erp-gateway-key-bhwl-2026"
|
||||
|
||||
async def _get(url: str, params: dict = None):
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
r = await c.get(url, headers={"X-API-Key": ERP_KEY}, params=params)
|
||||
return r.json()
|
||||
|
||||
async def calculate_all():
|
||||
db = get_session_local()()
|
||||
try:
|
||||
now = datetime.now()
|
||||
period = f"{now.year}-{now.month:02d}"
|
||||
|
||||
# 1. 从科目余额表取数据(BalanceInfo)
|
||||
balance_data = await _get(f"{ERP_API}/api/v1/query", {"table": "BalanceInfo", "limit": 200})
|
||||
balances = balance_data.get("data", [])
|
||||
|
||||
# 按科目和期间汇总
|
||||
revenue = 0 # 营业收入 (Act_ID=4)
|
||||
cost = 0 # 营业成本 (Act_ID=5)
|
||||
ar_balance = 0 # 应收账款 (Act_ID=3)
|
||||
inv_balance = 0 # 库存商品 (Act_ID=2)
|
||||
|
||||
for b in balances:
|
||||
aid = b.get("Act_ID")
|
||||
tot = float(b.get("Act_Tot", 0) or 0)
|
||||
hap = float(b.get("Act_Hap", 0) or 0)
|
||||
if aid == 4: # 营业收入(本期发生额更准确)
|
||||
revenue += hap if hap > 0 else tot
|
||||
elif aid == 5: # 营业成本
|
||||
cost += hap if hap > 0 else tot
|
||||
elif aid == 3: # 应收账款余额
|
||||
ar_balance = tot
|
||||
elif aid == 2: # 存货余额
|
||||
inv_balance = tot
|
||||
|
||||
# 2. 从销售总览取数据
|
||||
summary = await _get(f"{ERP_API}/api/v1/stats/sales-summary", {"year": now.year})
|
||||
s = summary.get("summary", {})
|
||||
total_sales = s.get("total_amount", 0)
|
||||
total_customers = s.get("customer_count", 0)
|
||||
|
||||
# 3. 前5客户集中度
|
||||
top_customers = await _get(f"{ERP_API}/api/v1/stats/customer-top", {"year": now.year, "limit": 5})
|
||||
top5_amt = sum(c["amount"] for c in top_customers.get("data", []))
|
||||
top5_ratio = round(top5_amt / total_sales * 100, 1) if total_sales > 0 else 0
|
||||
|
||||
# 4. 计算KPI
|
||||
gross_margin = round((revenue - cost) / revenue * 100, 2) if revenue > 0 else 0
|
||||
ar_turnover = round(revenue / ar_balance, 2) if ar_balance > 0 else 0
|
||||
inv_turnover = round(cost / inv_balance, 2) if inv_balance > 0 else 0
|
||||
|
||||
kpi_values = {
|
||||
"SALES_TOTAL": total_sales,
|
||||
"CUSTOMER_COUNT": total_customers,
|
||||
"SALES_PROFIT_RATE": gross_margin,
|
||||
"RECEIVABLE_TURNOVER": ar_turnover,
|
||||
"TURNOVER_RATE": inv_turnover,
|
||||
"TOP5_CUSTOMER_RATIO": top5_ratio,
|
||||
}
|
||||
|
||||
for code, value in kpi_values.items():
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if kpi and (value > 0 or kpi.kpi_code in ("SALES_PROFIT_RATE","RECEIVABLE_TURNOVER","TURNOVER_RATE")):
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.source_type == "erp",
|
||||
).first()
|
||||
if not existing:
|
||||
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=round(value, 2), source_type="erp", data_status="verified")
|
||||
db.add(kv)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ KPI计算完成: {period}")
|
||||
for code, value in kpi_values.items():
|
||||
kpi_n = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
n = kpi_n.kpi_name if kpi_n else code
|
||||
print(f" {n}: {round(value,2) if value else '-'}")
|
||||
except Exception as e:
|
||||
print(f"❌ KPI计算失败: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(calculate_all())
|
||||
@@ -0,0 +1,282 @@
|
||||
"""成本分析引擎 — 管理会计OS
|
||||
标准成本vs实际成本差异分析(量差/价差/效率差异)
|
||||
ABC作业成本法分配
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
from app.database import get_session_local
|
||||
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPIDefinition, KPIValue
|
||||
|
||||
logger = logging.getLogger("cma.cost")
|
||||
|
||||
ERP_API = "http://127.0.0.1:8300"
|
||||
ERP_KEY = "erp-gateway-key-bhwl-2026"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 差异计算
|
||||
# ============================================================
|
||||
|
||||
def calc_variance(standard_qty: float, actual_qty: float,
|
||||
standard_price: float, actual_price: float) -> dict:
|
||||
"""计算量差和价差
|
||||
|
||||
量差 = (实际用量 - 标准用量) × 标准价格
|
||||
价差 = (实际价格 - 标准价格) × 实际用量
|
||||
总差异 = 量差 + 价差
|
||||
"""
|
||||
qty_variance = round((actual_qty - standard_qty) * standard_price, 2)
|
||||
price_variance = round((actual_price - standard_price) * actual_qty, 2)
|
||||
total_variance = round(qty_variance + price_variance, 2)
|
||||
return {
|
||||
"qty_variance": qty_variance, # 量差
|
||||
"price_variance": price_variance, # 价差
|
||||
"total_variance": total_variance, # 总差异
|
||||
"standard_cost": round(standard_qty * standard_price, 2),
|
||||
"actual_cost": round(actual_qty * actual_price, 2),
|
||||
}
|
||||
|
||||
|
||||
def calc_efficiency_variance(standard_hours: float, actual_hours: float,
|
||||
standard_rate: float) -> dict:
|
||||
"""计算效率差异(人工/制造费用)
|
||||
|
||||
效率差异 = (实际工时 - 标准工时) × 标准分配率
|
||||
分配率差异 = (实际分配率 - 标准分配率) × 实际工时
|
||||
"""
|
||||
eff = round((actual_hours - standard_hours) * standard_rate, 2)
|
||||
# 假设实际分配率从外面传入
|
||||
return {
|
||||
"efficiency_variance": eff,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 产品级差异分析
|
||||
# ============================================================
|
||||
|
||||
def calc_product_variance(product_code: str, period: str) -> dict:
|
||||
"""计算指定产品在指定期间的成本差异"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
standards = db.query(StandardCost).filter(
|
||||
StandardCost.product_code == product_code,
|
||||
StandardCost.status == "active",
|
||||
).all()
|
||||
actuals = db.query(ActualCost).filter(
|
||||
ActualCost.product_code == product_code,
|
||||
ActualCost.period == period,
|
||||
).all()
|
||||
|
||||
if not standards or not actuals:
|
||||
return {"product_code": product_code, "error": "标准成本或实际成本数据不足", "items": [], "summary": {}}
|
||||
|
||||
# 按 cost_type 分组
|
||||
cost_types = set()
|
||||
for s in standards: cost_types.add(s.cost_type)
|
||||
for a in actuals: cost_types.add(a.cost_type)
|
||||
|
||||
items = []
|
||||
total_std = 0
|
||||
total_act = 0
|
||||
total_qty_var = 0
|
||||
total_price_var = 0
|
||||
|
||||
for ct in sorted(cost_types):
|
||||
std_items = [s for s in standards if s.cost_type == ct]
|
||||
act_items = [a for a in actuals if a.cost_type == ct]
|
||||
|
||||
if std_items and act_items:
|
||||
s = std_items[0]
|
||||
a = act_items[0]
|
||||
var = calc_variance(s.standard_quantity, a.actual_quantity,
|
||||
s.standard_price, a.actual_price)
|
||||
items.append({
|
||||
"cost_type": ct,
|
||||
"item_name": s.item_name,
|
||||
"standard_quantity": s.standard_quantity,
|
||||
"actual_quantity": a.actual_quantity,
|
||||
"standard_price": s.standard_price,
|
||||
"actual_price": a.actual_price,
|
||||
"standard_cost": var["standard_cost"],
|
||||
"actual_cost": var["actual_cost"],
|
||||
"qty_variance": var["qty_variance"],
|
||||
"price_variance": var["price_variance"],
|
||||
"total_variance": var["total_variance"],
|
||||
})
|
||||
total_std += var["standard_cost"]
|
||||
total_act += var["actual_cost"]
|
||||
total_qty_var += var["qty_variance"]
|
||||
total_price_var += var["price_variance"]
|
||||
|
||||
return {
|
||||
"product_code": product_code,
|
||||
"period": period,
|
||||
"items": items,
|
||||
"summary": {
|
||||
"total_standard_cost": round(total_std, 2),
|
||||
"total_actual_cost": round(total_act, 2),
|
||||
"total_variance": round(total_act - total_std, 2),
|
||||
"total_qty_variance": round(total_qty_var, 2),
|
||||
"total_price_variance": round(total_price_var, 2),
|
||||
"variance_rate": round((total_act - total_std) / total_std * 100, 2) if total_std else 0,
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ABC 作业成本分配
|
||||
# ============================================================
|
||||
|
||||
def calc_driver_rate(activity_id: int) -> dict:
|
||||
"""计算作业动因分配率 = 总成本 / 动因总量"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
|
||||
if not act or not act.driver_volume:
|
||||
return {"error": "作业中心不存在或动因总量为0"}
|
||||
rate = round(act.total_cost / act.driver_volume, 4) if act.driver_volume > 0 else 0
|
||||
act.driver_rate = rate
|
||||
db.commit()
|
||||
return {
|
||||
"activity_code": act.activity_code,
|
||||
"activity_name": act.activity_name,
|
||||
"total_cost": act.total_cost,
|
||||
"driver_volume": act.driver_volume,
|
||||
"driver_rate": rate,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def allocate_cost(activity_id: int, period: str, product_code: str,
|
||||
product_name: str, driver_consumed: float) -> dict:
|
||||
"""按动因分配成本到产品"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
|
||||
if not act or act.driver_rate == 0:
|
||||
# 自动计算分配率
|
||||
if act and act.driver_volume > 0:
|
||||
act.driver_rate = round(act.total_cost / act.driver_volume, 4)
|
||||
db.commit()
|
||||
if not act or act.driver_rate == 0:
|
||||
return {"error": "分配率未设置"}
|
||||
allocated = round(driver_consumed * act.driver_rate, 2)
|
||||
alloc = AbcAllocation(
|
||||
period=period,
|
||||
activity_id=activity_id,
|
||||
product_code=product_code,
|
||||
product_name=product_name,
|
||||
driver_consumed=driver_consumed,
|
||||
allocated_cost=allocated,
|
||||
)
|
||||
db.add(alloc)
|
||||
db.commit()
|
||||
return {
|
||||
"activity_code": act.activity_code,
|
||||
"product_code": product_code,
|
||||
"driver_consumed": driver_consumed,
|
||||
"driver_rate": act.driver_rate,
|
||||
"allocated_cost": allocated,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 成本总览数据
|
||||
# ============================================================
|
||||
|
||||
def get_cost_overview(period: str) -> dict:
|
||||
"""获取成本总览数据(总成本、结构占比、趋势)"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
# 从实际成本表汇总
|
||||
actual_costs = db.query(ActualCost).filter(ActualCost.period == period).all()
|
||||
total_cost = sum(a.actual_cost for a in actual_costs)
|
||||
|
||||
# 按成本类型分组
|
||||
by_type: Dict[str, float] = {}
|
||||
for a in actual_costs:
|
||||
by_type[a.cost_type] = by_type.get(a.cost_type, 0) + a.actual_cost
|
||||
|
||||
structure = [
|
||||
{"cost_type": k, "amount": round(v, 2), "ratio": round(v / total_cost * 100, 1) if total_cost else 0}
|
||||
for k, v in sorted(by_type.items())
|
||||
]
|
||||
|
||||
# 从ERP获取成本数据做补充
|
||||
import httpx
|
||||
try:
|
||||
resp = httpx.get(f"{ERP_API}/api/v1/query", params={"table": "BalanceInfo", "limit": 100},
|
||||
headers={"X-API-Key": ERP_KEY}, timeout=10)
|
||||
balance_data = resp.json().get("data", [])
|
||||
erp_cost = 0
|
||||
for b in balance_data:
|
||||
if b.get("Act_ID") == 5: # 营业成本
|
||||
erp_cost += float(b.get("Act_Hap", 0) or 0) + float(b.get("Act_Tot", 0) or 0)
|
||||
except Exception:
|
||||
erp_cost = 0
|
||||
|
||||
# 历史趋势(近6个月)
|
||||
from sqlalchemy import text
|
||||
year = period[:4]
|
||||
months_texts = []
|
||||
try:
|
||||
m = int(period.split("-")[1])
|
||||
for i in range(6):
|
||||
pm = m - i
|
||||
py = int(year)
|
||||
while pm <= 0:
|
||||
pm += 12
|
||||
py -= 1
|
||||
months_texts.append(f"{py}-{pm:02d}")
|
||||
|
||||
trend = []
|
||||
for p in reversed(months_texts):
|
||||
costs = db.query(ActualCost).filter(ActualCost.period == p).all()
|
||||
total = round(sum(c.actual_cost for c in costs), 2)
|
||||
trend.append({"period": p, "total_cost": total})
|
||||
except Exception:
|
||||
trend = []
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"total_cost": round(total_cost + erp_cost, 2),
|
||||
"erp_cost": round(erp_cost, 2),
|
||||
"manual_cost": round(total_cost, 2),
|
||||
"structure": structure,
|
||||
"trend": trend,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_cost_breakdown(product_code: str, period: str) -> dict:
|
||||
"""获取成本构成(料/工/费占比)"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
actuals = db.query(ActualCost).filter(
|
||||
ActualCost.product_code == product_code,
|
||||
ActualCost.period == period,
|
||||
).all()
|
||||
|
||||
material = sum(a.actual_cost for a in actuals if a.cost_type == "material")
|
||||
labor = sum(a.actual_cost for a in actuals if a.cost_type == "labor")
|
||||
overhead = sum(a.actual_cost for a in actuals if a.cost_type == "overhead")
|
||||
total = material + labor + overhead
|
||||
|
||||
return {
|
||||
"product_code": product_code,
|
||||
"period": period,
|
||||
"material": {"amount": round(material, 2), "ratio": round(material / total * 100, 1) if total else 0},
|
||||
"labor": {"amount": round(labor, 2), "ratio": round(labor / total * 100, 1) if total else 0},
|
||||
"overhead": {"amount": round(overhead, 2), "ratio": round(overhead / total * 100, 1) if total else 0},
|
||||
"total": round(total, 2),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,311 @@
|
||||
"""差异预警引擎 — 管理会计OS
|
||||
实际 vs 预算/目标对比,超阈值自动推送预警
|
||||
|
||||
功能:
|
||||
1. 实际 vs 预算差异计算(差异额/差异率)
|
||||
2. 同比/环比差异计算
|
||||
3. 趋势异常检测(连续N期下滑/上升)
|
||||
4. 差异预警触发(集成到现有预警系统)
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
|
||||
|
||||
logger = logging.getLogger("cma.deviation")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 差异计算
|
||||
# ============================================================
|
||||
|
||||
def calc_deviation(actual: float, budget: float) -> dict:
|
||||
"""计算差异额和差异率"""
|
||||
if budget is None or budget == 0:
|
||||
return {
|
||||
"deviation_amount": None,
|
||||
"deviation_rate": None,
|
||||
"is_over_budget": None,
|
||||
}
|
||||
amount = round(actual - budget, 2)
|
||||
rate = round(amount / budget * 100, 2)
|
||||
return {
|
||||
"deviation_amount": amount,
|
||||
"deviation_rate": rate,
|
||||
"is_over_budget": amount > 0,
|
||||
}
|
||||
|
||||
|
||||
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的预算值"""
|
||||
query = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
)
|
||||
if version:
|
||||
query = query.filter(BudgetPlan.version == version)
|
||||
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
|
||||
return plan.budget_value if plan else None
|
||||
|
||||
|
||||
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的实际值"""
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
return val.actual_value if val else None
|
||||
|
||||
|
||||
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
|
||||
"""单KPI单期的差异计算"""
|
||||
actual = get_actual_for_kpi(db, kpi_id, period)
|
||||
budget = get_budget_for_kpi(db, kpi_id, period)
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_code = kpi.kpi_code if kpi else "unknown"
|
||||
|
||||
# 如果没预算值,用 target_value 作为替代
|
||||
if budget is None and kpi:
|
||||
# 尝试把年度目标按月均分
|
||||
month = int(period.split("-")[1])
|
||||
target = kpi.target_value
|
||||
if target and target > 0 and kpi.frequency == "monthly":
|
||||
budget = round(target / 12, 2)
|
||||
|
||||
deviation = calc_deviation(actual, budget) if actual is not None else None
|
||||
|
||||
result = {
|
||||
"kpi_id": kpi_id,
|
||||
"kpi_code": kpi_code,
|
||||
"period": period,
|
||||
"actual_value": actual,
|
||||
"budget_value": budget,
|
||||
}
|
||||
if deviation:
|
||||
result.update(deviation)
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同比/环比差异
|
||||
# ============================================================
|
||||
|
||||
def calc_period_diff(db, kpi_id: int, current_period: str, diff_type: str = "yoy") -> dict:
|
||||
"""计算同比(上年同期)或环比(上期)差异"""
|
||||
year, month = current_period.split("-")
|
||||
y, m = int(year), int(month)
|
||||
|
||||
if diff_type == "yoy":
|
||||
# 同比:上年同期
|
||||
prev_period = f"{y-1}-{m:02d}"
|
||||
label = "同比"
|
||||
elif diff_type == "mom":
|
||||
# 环比:上个月
|
||||
prev_m = m - 1
|
||||
prev_y = y
|
||||
if prev_m <= 0:
|
||||
prev_m += 12
|
||||
prev_y -= 1
|
||||
prev_period = f"{prev_y}-{prev_m:02d}"
|
||||
label = "环比"
|
||||
else:
|
||||
return {"error": f"未知比较类型: {diff_type}"}
|
||||
|
||||
current = get_actual_for_kpi(db, kpi_id, current_period)
|
||||
previous = get_actual_for_kpi(db, kpi_id, prev_period)
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_code = kpi.kpi_code if kpi else "unknown"
|
||||
|
||||
if current is None or previous is None or previous == 0:
|
||||
return {
|
||||
"kpi_id": kpi_id,
|
||||
"kpi_code": kpi_code,
|
||||
"type": diff_type,
|
||||
"label": label,
|
||||
"current_period": current_period,
|
||||
"prev_period": prev_period,
|
||||
"current_value": current,
|
||||
"prev_value": previous,
|
||||
"diff_amount": None,
|
||||
"diff_rate": None,
|
||||
}
|
||||
|
||||
diff_amount = round(current - previous, 2)
|
||||
diff_rate = round(diff_amount / previous * 100, 2)
|
||||
return {
|
||||
"kpi_id": kpi_id,
|
||||
"kpi_code": kpi_code,
|
||||
"type": diff_type,
|
||||
"label": label,
|
||||
"current_period": current_period,
|
||||
"prev_period": prev_period,
|
||||
"current_value": current,
|
||||
"prev_value": previous,
|
||||
"diff_amount": diff_amount,
|
||||
"diff_rate": diff_rate,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 趋势检测
|
||||
# ============================================================
|
||||
|
||||
def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> dict:
|
||||
"""检测连续N期下滑或上升的趋势异常"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
return {"anomaly": False}
|
||||
|
||||
# 获取包括当前期在内的近期数据
|
||||
year, month = period.split("-")
|
||||
y, m = int(year), int(month)
|
||||
|
||||
values = []
|
||||
for i in range(consecutive + 2): # 多取2期做参考
|
||||
p = f"{y}-{m:02d}"
|
||||
v = get_actual_for_kpi(db, kpi_id, p)
|
||||
if v is not None:
|
||||
values.append({"period": p, "value": v})
|
||||
m -= 1
|
||||
if m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
|
||||
values.reverse() # 按时间正序
|
||||
if len(values) < consecutive:
|
||||
return {"anomaly": False, "reason": "数据不足"}
|
||||
|
||||
last_n = values[-consecutive:]
|
||||
all_decreasing = all(last_n[i]["value"] > last_n[i + 1]["value"] for i in range(len(last_n) - 1))
|
||||
all_increasing = all(last_n[i]["value"] < last_n[i + 1]["value"] for i in range(len(last_n) - 1))
|
||||
|
||||
if all_decreasing:
|
||||
return {
|
||||
"anomaly": True,
|
||||
"type": "continuous_decline",
|
||||
"level": "yellow" if consecutive >= 3 else "green",
|
||||
"periods": [v["period"] for v in last_n],
|
||||
"values": [v["value"] for v in last_n],
|
||||
"message": f"{kpi.kpi_name} 连续{consecutive}期下滑",
|
||||
}
|
||||
if all_increasing:
|
||||
return {
|
||||
"anomaly": True,
|
||||
"type": "continuous_rise",
|
||||
"level": "yellow" if consecutive >= 3 else "green",
|
||||
"periods": [v["period"] for v in last_n],
|
||||
"values": [v["value"] for v in last_n],
|
||||
"message": f"{kpi.kpi_name} 连续{consecutive}期上升(可能过热)",
|
||||
}
|
||||
|
||||
return {"anomaly": False}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 差异预警触发
|
||||
# ============================================================
|
||||
|
||||
def run_deviation_check(db_session, period: str = None) -> int:
|
||||
"""运行差异预警检查,返回新增预警数"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
kpis = db_session.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active"
|
||||
).all()
|
||||
|
||||
new_count = 0
|
||||
for kpi in kpis:
|
||||
# 1. 差异预警:实际 vs 预算
|
||||
deviation = calc_period_deviation(db_session, kpi.id, period)
|
||||
if deviation.get("deviation_rate") is not None:
|
||||
rate = abs(deviation["deviation_rate"])
|
||||
|
||||
# 差异化阈值:越高越好型 vs 越低越好型
|
||||
higher_better = kpi.kpi_code in [
|
||||
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
|
||||
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
|
||||
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
|
||||
]
|
||||
|
||||
if higher_better:
|
||||
# 实际低于预算才是问题
|
||||
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
|
||||
level = "yellow" if rate >= 10 else "green"
|
||||
level = "red" if rate >= 30 else level
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
# 实际高于预算才是问题(成本型)
|
||||
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
|
||||
level = "yellow" if rate >= 10 else "green"
|
||||
level = "red" if rate >= 30 else level
|
||||
else:
|
||||
continue
|
||||
|
||||
alert_msg = (
|
||||
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
||||
f"vs 预算{deviation['budget_value']},"
|
||||
f"差异率{deviation['deviation_rate']}%"
|
||||
)
|
||||
|
||||
# 检查是否已有同KPI同期间的差异预警
|
||||
existing = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[差异预警]"),
|
||||
KPIAlert.alert_message.contains(period),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=level,
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
|
||||
# 2. 趋势异常检测(每期检查连续3期)
|
||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
|
||||
trend_alert_msg = trend["message"]
|
||||
existing_trend = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[趋势预警]"),
|
||||
KPIAlert.status.in_(["pending", "processing"]),
|
||||
).first()
|
||||
|
||||
if not existing_trend:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=trend["level"],
|
||||
alert_message=f"[趋势预警] {trend_alert_msg}",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
|
||||
|
||||
db_session.commit()
|
||||
return new_count
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
db = get_session_local()()
|
||||
try:
|
||||
n = run_deviation_check(db)
|
||||
print(f"差异预警检查完成: 新增 {n} 条")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
预警通知推送模块 — 管理会计OS
|
||||
支持渠道:企业微信 (群机器人/应用消息)、邮件
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.header import Header
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("cma.notifier")
|
||||
|
||||
# ============================================================
|
||||
# 企业微信机器人推送
|
||||
# ============================================================
|
||||
|
||||
def send_wecom_robot(webhook_url: str, title: str, content: str, alert_level: str = "yellow") -> dict:
|
||||
"""通过企业微信群机器人发送告警"""
|
||||
color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪")
|
||||
msg = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": f"## {color_tag} 管理会计OS预警通知\n"
|
||||
f"**{title}**\n\n"
|
||||
f"{content}\n\n"
|
||||
f"---\n"
|
||||
f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
}
|
||||
}
|
||||
data = json.dumps(msg).encode("utf-8")
|
||||
req = urllib.request.Request(webhook_url, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
if result.get("errcode") == 0:
|
||||
return {"success": True, "message": "已推送至企业微信群"}
|
||||
else:
|
||||
return {"success": False, "message": f"推送失败: {result.get('errmsg', '未知错误')}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"推送异常: {str(e)}"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 企业微信应用消息推送(通过自建应用 message/send API)
|
||||
# ============================================================
|
||||
|
||||
def send_wecom_app(corp_id: str, corp_secret: str, agent_id: str,
|
||||
touser: str, title: str, content: str, alert_level: str = "yellow") -> dict:
|
||||
"""通过企微自建应用发送应用消息"""
|
||||
import requests
|
||||
try:
|
||||
token_url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={corp_secret}"
|
||||
r = requests.get(token_url, timeout=10)
|
||||
token_data = r.json()
|
||||
if token_data.get("errcode") != 0:
|
||||
return {"success": False, "message": f"获取token失败: {token_data.get('errmsg', '')}"}
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
color_tag = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(alert_level, "⚪")
|
||||
md = "## " + color_tag + " 管理会计OS预警通知\n\n"
|
||||
md += "**" + title + "**\n\n"
|
||||
md += content + "\n\n---\n"
|
||||
md += "⏰ " + datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
payload = {
|
||||
"touser": touser,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(agent_id),
|
||||
"markdown": {"content": md},
|
||||
"safe": 0,
|
||||
}
|
||||
send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + access_token
|
||||
r2 = requests.post(send_url, json=payload, timeout=10)
|
||||
send_data = r2.json()
|
||||
if send_data.get("errcode") == 0:
|
||||
return {"success": True, "message": f"已推送到企微用户 {touser}"}
|
||||
else:
|
||||
return {"success": False, "message": f"推送失败: {send_data.get('errmsg', '')}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"推送异常: {str(e)}"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 邮件推送
|
||||
# ============================================================
|
||||
|
||||
def send_mail(smtp_config: dict, to_addrs: list, title: str, content: str) -> dict:
|
||||
"""通过 SMTP 发送邮件告警"""
|
||||
try:
|
||||
msg = MIMEText(content, "plain", "utf-8")
|
||||
msg["Subject"] = Header(f"[管理会计OS预警] {title}", "utf-8")
|
||||
msg["From"] = smtp_config.get("from_addr", "")
|
||||
msg["To"] = ", ".join(to_addrs)
|
||||
|
||||
host = smtp_config.get("host", "smtp.qq.com")
|
||||
port = int(smtp_config.get("port", 465))
|
||||
user = smtp_config.get("user", "")
|
||||
password = smtp_config.get("password", "")
|
||||
use_ssl = smtp_config.get("use_ssl", True)
|
||||
|
||||
if use_ssl:
|
||||
server = smtplib.SMTP_SSL(host, port, timeout=10)
|
||||
else:
|
||||
server = smtplib.SMTP(host, port, timeout=10)
|
||||
server.starttls()
|
||||
|
||||
server.login(user, password)
|
||||
server.sendmail(user, to_addrs, msg.as_string())
|
||||
server.quit()
|
||||
return {"success": True, "message": f"已发送邮件至 {', '.join(to_addrs)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"邮件发送失败: {str(e)}"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 主推送函数
|
||||
# ============================================================
|
||||
|
||||
def push_alert(alert: dict, channels: list[dict]) -> list[dict]:
|
||||
"""向所有已启用渠道推送一条预警"""
|
||||
results = []
|
||||
alert_level = alert.get("alert_level", "yellow")
|
||||
title = alert.get("alert_message", "预警通知")
|
||||
content = _build_content(alert)
|
||||
|
||||
for ch in channels:
|
||||
if not ch.get("enabled", True):
|
||||
continue
|
||||
|
||||
ch_type = ch.get("channel_type", "")
|
||||
config = ch.get("config", {})
|
||||
result = {"channel": ch_type, "channel_name": ch.get("name", ""), "success": False}
|
||||
|
||||
if ch_type == "wecom":
|
||||
webhook = config.get("webhook_url", "")
|
||||
if webhook:
|
||||
result = send_wecom_robot(webhook, title, content, alert_level)
|
||||
result["channel"] = "wecom"
|
||||
|
||||
elif ch_type == "wecom_app":
|
||||
result = send_wecom_app(
|
||||
corp_id=config.get("corp_id", ""),
|
||||
corp_secret=config.get("corp_secret", ""),
|
||||
agent_id=config.get("agent_id", ""),
|
||||
touser=config.get("touser", ""),
|
||||
title=title, content=content, alert_level=alert_level,
|
||||
)
|
||||
result["channel"] = "wecom_app"
|
||||
|
||||
elif ch_type == "mail":
|
||||
to_list = config.get("to", [])
|
||||
if to_list:
|
||||
result = send_mail(config, to_list, title, content)
|
||||
result["channel"] = "mail"
|
||||
|
||||
results.append({**result, "channel_name": ch.get("name", "")})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _build_content(alert: dict) -> str:
|
||||
"""构建预警详情内容"""
|
||||
parts = [
|
||||
f"KPI: {alert.get('kpi_name', '未知')}",
|
||||
f"期间: {alert.get('period', '')}",
|
||||
f"实际值: {alert.get('actual_value', '-')}",
|
||||
f"目标值: {alert.get('target_value', '-')}",
|
||||
f"预警级别: {'🔴 紧急' if alert.get('alert_level') == 'red' else '🟡 警告'}",
|
||||
]
|
||||
if alert.get("resolution"):
|
||||
parts.append(f"处理建议: {alert['resolution']}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 从数据库加载渠道配置并推送待处理预警
|
||||
# ============================================================
|
||||
|
||||
def push_pending_alerts(db_session) -> int:
|
||||
"""推送所有待处理预警"""
|
||||
from app.models import NotificationChannel, NotificationLog, KPIAlert, KPIDefinition, KPIValue
|
||||
|
||||
# 加载已启用的通知渠道
|
||||
channels = db_session.query(NotificationChannel).filter(
|
||||
NotificationChannel.enabled == True
|
||||
).all()
|
||||
|
||||
if not channels:
|
||||
logger.info("无已启用的通知渠道,跳过推送")
|
||||
return 0
|
||||
|
||||
# 查待处理的预警
|
||||
alerts = db_session.query(KPIAlert).filter(
|
||||
KPIAlert.status == "pending"
|
||||
).all()
|
||||
|
||||
if not alerts:
|
||||
logger.info("无待处理预警")
|
||||
return 0
|
||||
|
||||
channel_configs = [json.loads(json.dumps({
|
||||
"name": c.name, "channel_type": c.channel_type,
|
||||
"config": c.config, "enabled": c.enabled
|
||||
})) for c in channels]
|
||||
|
||||
pushed = 0
|
||||
for alert in alerts:
|
||||
# 获取预警详情
|
||||
kpi = db_session.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == alert.kpi_id
|
||||
).first()
|
||||
kpi_value = db_session.query(KPIValue).filter(
|
||||
KPIValue.id == alert.kpi_value_id
|
||||
).first()
|
||||
|
||||
alert_data = {
|
||||
"alert_level": alert.alert_level,
|
||||
"alert_message": alert.alert_message,
|
||||
"kpi_name": kpi.kpi_name if kpi else "未知",
|
||||
"period": kpi_value.period if kpi_value else "",
|
||||
"actual_value": kpi_value.actual_value if kpi_value else None,
|
||||
"target_value": kpi.target_value if kpi else None,
|
||||
}
|
||||
|
||||
results = push_alert(alert_data, channel_configs)
|
||||
|
||||
# 记录推送日志
|
||||
for r in results:
|
||||
log = NotificationLog(
|
||||
alert_id=alert.id,
|
||||
channel=r.get("channel", ""),
|
||||
recipient=r.get("channel_name", ""),
|
||||
title=alert.alert_message[:200],
|
||||
content=alert.alert_message,
|
||||
status="sent" if r.get("success") else "failed",
|
||||
error_msg=r.get("message") if not r.get("success") else None,
|
||||
sent_at=datetime.now(),
|
||||
)
|
||||
db_session.add(log)
|
||||
if r.get("success"):
|
||||
pushed += 1
|
||||
logger.info(f" 已推送预警 #{alert.id} -> {r.get('channel_name')}")
|
||||
|
||||
db_session.commit()
|
||||
return pushed
|
||||
@@ -0,0 +1,260 @@
|
||||
"""预测模拟引擎 — 管理会计OS
|
||||
CVP本量利分析、投资决策(NPV/IRR)、敏感性分析、情景模拟
|
||||
"""
|
||||
import math
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("cma.predict")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CVP 本量利分析
|
||||
# ============================================================
|
||||
|
||||
def cvp_analysis(
|
||||
unit_price: float, # 单价
|
||||
unit_variable_cost: float, # 单位变动成本
|
||||
fixed_cost: float, # 固定成本
|
||||
target_profit: float = None, # 目标利润(可选)
|
||||
actual_volume: float = None, # 实际销量(可选)
|
||||
) -> dict:
|
||||
"""CVP本量利分析
|
||||
|
||||
返回:盈亏平衡点、安全边际、目标利润所需销量
|
||||
"""
|
||||
if unit_price <= unit_variable_cost:
|
||||
return {"error": "单价必须大于单位变动成本"}
|
||||
|
||||
contribution_margin = unit_price - unit_variable_cost # 单位边际贡献
|
||||
contribution_ratio = round(contribution_margin / unit_price * 100, 2) # 边际贡献率
|
||||
|
||||
# 盈亏平衡点(保本点)
|
||||
bep_units = round(fixed_cost / contribution_margin, 2) # 保本销量
|
||||
bep_revenue = round(bep_units * unit_price, 2) # 保本销售额
|
||||
|
||||
result = {
|
||||
"unit_price": unit_price,
|
||||
"unit_variable_cost": unit_variable_cost,
|
||||
"fixed_cost": fixed_cost,
|
||||
"contribution_margin": round(contribution_margin, 2),
|
||||
"contribution_ratio": contribution_ratio,
|
||||
"bep_units": bep_units,
|
||||
"bep_revenue": bep_revenue,
|
||||
}
|
||||
|
||||
# 安全边际
|
||||
if actual_volume is not None:
|
||||
safety_margin_units = actual_volume - bep_units
|
||||
safety_margin_ratio = round(safety_margin_units / actual_volume * 100, 2) if actual_volume > 0 else 0
|
||||
actual_profit = round((unit_price - unit_variable_cost) * actual_volume - fixed_cost, 2)
|
||||
result["safety_margin_units"] = round(safety_margin_units, 2)
|
||||
result["safety_margin_revenue"] = round(safety_margin_units * unit_price, 2)
|
||||
result["safety_margin_ratio"] = safety_margin_ratio
|
||||
result["actual_profit"] = actual_profit
|
||||
|
||||
# 目标利润
|
||||
if target_profit is not None:
|
||||
target_units = round((fixed_cost + target_profit) / contribution_margin, 2)
|
||||
target_revenue = round(target_units * unit_price, 2)
|
||||
result["target_profit"] = target_profit
|
||||
result["target_units"] = target_units
|
||||
result["target_revenue"] = target_revenue
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 投资决策模型
|
||||
# ============================================================
|
||||
|
||||
def npv(initial_investment: float, cash_flows: List[float], discount_rate: float) -> dict:
|
||||
"""计算净现值 NPV = Σ CFt / (1+r)^t - I0"""
|
||||
if not cash_flows:
|
||||
return {"error": "现金流列表不能为空"}
|
||||
r = discount_rate / 100
|
||||
pv = 0
|
||||
for t, cf in enumerate(cash_flows, 1):
|
||||
pv += cf / ((1 + r) ** t)
|
||||
npv_value = round(pv - initial_investment, 2)
|
||||
|
||||
# 盈利能力指数 PI = PV / I0
|
||||
pi = round(pv / initial_investment, 4) if initial_investment > 0 else 0
|
||||
|
||||
return {
|
||||
"initial_investment": initial_investment,
|
||||
"discount_rate": discount_rate,
|
||||
"pv_of_cash_flows": round(pv, 2),
|
||||
"npv": npv_value,
|
||||
"profitability_index": pi,
|
||||
"is_viable": npv_value > 0,
|
||||
}
|
||||
|
||||
|
||||
def irr(initial_investment: float, cash_flows: List[float], max_iter: int = 1000, tolerance: float = 1e-6) -> dict:
|
||||
"""计算内部收益率 IRR(迭代法)"""
|
||||
if not cash_flows:
|
||||
return {"error": "现金流列表不能为空"}
|
||||
|
||||
# 确保现金流总和 > 初始投资(否则 IRR 可能为负)
|
||||
total_cf = sum(cash_flows)
|
||||
if total_cf <= initial_investment:
|
||||
# 用牛顿法尝试求负IRR
|
||||
pass
|
||||
|
||||
def _npv_at(rate: float) -> float:
|
||||
return sum(cf / ((1 + rate) ** (t + 1)) for t, cf in enumerate(cash_flows)) - initial_investment
|
||||
|
||||
# 牛顿法求根
|
||||
rate = 0.1 # 初始猜测 10%
|
||||
for _ in range(max_iter):
|
||||
f = _npv_at(rate)
|
||||
if abs(f) < tolerance:
|
||||
break
|
||||
# 导数近似
|
||||
h = 1e-4
|
||||
df = (_npv_at(rate + h) - _npv_at(rate - h)) / (2 * h)
|
||||
if abs(df) < tolerance:
|
||||
break
|
||||
rate -= f / df
|
||||
if rate < -0.99: # IRR 不能低于 -99%
|
||||
rate = -0.99
|
||||
break
|
||||
|
||||
irr_value = round(rate * 100, 2)
|
||||
|
||||
# 回收期
|
||||
cumulative = 0
|
||||
payback_period = None
|
||||
for t, cf in enumerate(cash_flows, 1):
|
||||
cumulative += cf
|
||||
if cumulative >= initial_investment:
|
||||
payback_period = t
|
||||
break
|
||||
|
||||
# 动态回收期(折现)
|
||||
r = irr_value / 100 if irr_value > 0 else 0.1
|
||||
discounted_cumulative = 0
|
||||
discounted_payback = None
|
||||
for t, cf in enumerate(cash_flows, 1):
|
||||
discounted_cumulative += cf / ((1 + r) ** t)
|
||||
if discounted_cumulative >= initial_investment:
|
||||
discounted_payback = t
|
||||
break
|
||||
|
||||
return {
|
||||
"initial_investment": initial_investment,
|
||||
"cash_flows": cash_flows,
|
||||
"irr": irr_value,
|
||||
"payback_period": payback_period, # 静态回收期(年)
|
||||
"discounted_payback_period": discounted_payback, # 动态回收期
|
||||
"is_viable": irr_value > 0,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 敏感性分析
|
||||
# ============================================================
|
||||
|
||||
def sensitivity_analysis(
|
||||
base_revenue: float, # 基准收入
|
||||
base_cost: float, # 基准成本
|
||||
base_profit: float = None, # 基准利润(若为None则自动 = 收入-成本)
|
||||
step: float = 5, # 步长 %
|
||||
max_step: float = 20, # 最大变动 %
|
||||
) -> dict:
|
||||
"""单因素敏感性分析
|
||||
|
||||
分析销量、单价、成本变动对利润的影响
|
||||
"""
|
||||
if base_profit is None:
|
||||
base_profit = base_revenue - base_cost
|
||||
|
||||
factors = []
|
||||
steps = [s for s in range(-max_step, max_step + 1, step)] or [0]
|
||||
|
||||
for pct in steps:
|
||||
factor = pct / 100
|
||||
|
||||
# 收入变动(销量变动)
|
||||
revenue_change_profit = base_profit * (1 + factor)
|
||||
rev_sensitivity = round((revenue_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
|
||||
|
||||
# 成本变动
|
||||
cost_change_profit = base_profit - base_cost * factor
|
||||
cost_sensitivity = round((cost_change_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
|
||||
|
||||
# 同时变动(收入+5%同时成本+5%)
|
||||
both_profit = (base_revenue * (1 + factor)) - (base_cost * (1 + factor))
|
||||
both_sensitivity = round((both_profit - base_profit) / base_profit * 100, 2) if base_profit else 0
|
||||
|
||||
factors.append({
|
||||
"change_pct": pct,
|
||||
"revenue_change_profit": round(revenue_change_profit, 2),
|
||||
"revenue_sensitivity": rev_sensitivity,
|
||||
"cost_change_profit": round(cost_change_profit, 2),
|
||||
"cost_sensitivity": cost_sensitivity,
|
||||
"both_change_profit": round(both_profit, 2),
|
||||
"both_sensitivity": both_sensitivity,
|
||||
})
|
||||
|
||||
return {
|
||||
"base_revenue": base_revenue,
|
||||
"base_cost": base_cost,
|
||||
"base_profit": round(base_profit, 2),
|
||||
"step": step,
|
||||
"max_step": max_step,
|
||||
"factors": factors,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 情景模拟
|
||||
# ============================================================
|
||||
|
||||
def scenario_analysis(
|
||||
optimistic: dict, # {"revenue": 130, "cost": 90}
|
||||
pessimistic: dict, # {"revenue": 80, "cost": 110}
|
||||
base: dict, # {"revenue": 100, "cost": 100}
|
||||
) -> dict:
|
||||
"""三情景模拟(乐观/中性/悲观)
|
||||
|
||||
每个情景包含 revenue(收入) 和 cost(成本)
|
||||
计算各情景下的利润和偏差
|
||||
"""
|
||||
scenarios = []
|
||||
for label, data in [("乐观", optimistic), ("中性", base), ("悲观", pessimistic)]:
|
||||
revenue = data.get("revenue", 0)
|
||||
cost = data.get("cost", 0)
|
||||
profit = round(revenue - cost, 2)
|
||||
scenarios.append({
|
||||
"scenario": label,
|
||||
"revenue": revenue,
|
||||
"cost": cost,
|
||||
"profit": profit,
|
||||
"profit_margin": round(profit / revenue * 100, 2) if revenue else 0,
|
||||
})
|
||||
|
||||
base_profit = scenarios[1]["profit"] # 中性情景利润
|
||||
for s in scenarios:
|
||||
if base_profit:
|
||||
s["deviation_from_base"] = round(s["profit"] - base_profit, 2)
|
||||
s["deviation_pct"] = round((s["profit"] - base_profit) / base_profit * 100, 2)
|
||||
else:
|
||||
s["deviation_from_base"] = s["profit"]
|
||||
s["deviation_pct"] = 0
|
||||
|
||||
# 最好/最坏/期望值(假设各1/3概率)
|
||||
expected_profit = round(
|
||||
(scenarios[0]["profit"] + scenarios[1]["profit"] + scenarios[2]["profit"]) / 3, 2
|
||||
)
|
||||
variance = sum((s["profit"] - expected_profit) ** 2 for s in scenarios) / 3
|
||||
std_dev = round(math.sqrt(variance), 2)
|
||||
|
||||
return {
|
||||
"scenarios": scenarios,
|
||||
"expected_profit": expected_profit,
|
||||
"std_deviation": std_dev,
|
||||
"best_case": scenarios[0],
|
||||
"worst_case": scenarios[2],
|
||||
}
|
||||
Reference in New Issue
Block a user