feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
"""
CMA管理报表中心 — 管理会计OS
非传统财务报表,聚焦管理决策分析
报表:
1. 管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润
2. 预算执行报告 — 各KPI预算vs实际vs差异率
3. KPI趋势报告 — 选定KPI的历史趋势
4. 四维度绩效评分卡 — BSC健康度雷达图
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime, date
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
import logging
logger = logging.getLogger("cma.reports")
router = APIRouter(prefix="/api/cma/reports", tags=["管理报表"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
# ============================================================
# 报表1: 管理利润表
# ============================================================
@router.get("/profit-summary")
def get_profit_summary(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
):
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
if period is None:
period = datetime.now().strftime("%Y-%m")
# 从KPI数据中获取各利润要素
def get_val(code: str):
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period
).order_by(KPIValue.id.desc()).first()
return v.actual_value if v else None
revenue = get_val("F_REVENUE")
gross_profit_rate = get_val("F_PROFIT_RATE")
net_profit_rate = get_val("F_NET_PROFIT_RATE")
cost_ratio = get_val("F_COST_RATIO")
# 计算利润要素
# 营收已知,用毛利率算毛利,用成本率算成本
gross_profit = round(revenue * (gross_profit_rate / 100), 2) if revenue and gross_profit_rate else None
total_cost = round(revenue * (cost_ratio / 100), 2) if revenue and cost_ratio else None
net_profit = round(revenue * (net_profit_rate / 100), 2) if revenue and net_profit_rate else None
# 边际贡献 ≈ 毛利(简化模型)
contribution_margin = gross_profit
# 固定成本 ≈ 总成本 - 变动成本(假设变动成本=营收*50%)
variable_cost = round(revenue * 0.50, 2) if revenue else None
fixed_cost = round(total_cost - variable_cost, 2) if total_cost and variable_cost else None
# 找上期做环比
prev_year, prev_month = period.split("-")
py, pm = int(prev_year), int(prev_month)
pm -= 1
if pm <= 0:
pm += 12
py -= 1
prev_period = f"{py}-{pm:02d}"
def get_prev_val(code: str):
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi: return None
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == prev_period
).order_by(KPIValue.id.desc()).first()
return v.actual_value if v else None
prev_revenue = get_prev_val("F_REVENUE")
prev_gross_profit_rate = get_prev_val("F_PROFIT_RATE")
prev_net_profit_rate = get_prev_val("F_NET_PROFIT_RATE")
prev_cost_ratio = get_prev_val("F_COST_RATIO")
prev_gross_profit = round(prev_revenue * (prev_gross_profit_rate / 100), 2) if prev_revenue and prev_gross_profit_rate else None
prev_total_cost = round(prev_revenue * (prev_cost_ratio / 100), 2) if prev_revenue and prev_cost_ratio else None
prev_net_profit = round(prev_revenue * (prev_net_profit_rate / 100), 2) if prev_revenue and prev_net_profit_rate else None
prev_contribution_margin = prev_gross_profit
prev_variable_cost = round(prev_revenue * 0.50, 2) if prev_revenue else None
prev_fixed_cost = round(prev_total_cost - prev_variable_cost, 2) if prev_total_cost and prev_variable_cost else None
def calc_chg(cur, prev):
if cur is not None and prev is not None and prev != 0:
return round((cur - prev) / prev * 100, 2)
return None
items = [
{
"name": "营业收入",
"value": revenue,
"prev_value": prev_revenue,
"change_rate": calc_chg(revenue, prev_revenue),
"ratio": 100.0,
},
{
"name": "减:变动成本",
"value": variable_cost,
"prev_value": prev_variable_cost,
"change_rate": calc_chg(variable_cost, prev_variable_cost),
"ratio": round(variable_cost / revenue * 100, 2) if variable_cost and revenue else None,
},
{
"name": " 边际贡献",
"value": contribution_margin,
"prev_value": prev_contribution_margin,
"change_rate": calc_chg(contribution_margin, prev_contribution_margin),
"ratio": round(contribution_margin / revenue * 100, 2) if contribution_margin and revenue else None,
"is_subtotal": True,
},
{
"name": "减:固定成本",
"value": fixed_cost,
"prev_value": prev_fixed_cost,
"change_rate": calc_chg(fixed_cost, prev_fixed_cost),
"ratio": round(fixed_cost / revenue * 100, 2) if fixed_cost and revenue else None,
},
{
"name": " 息税前利润",
"value": net_profit,
"prev_value": prev_net_profit,
"change_rate": calc_chg(net_profit, prev_net_profit),
"ratio": round(net_profit / revenue * 100, 2) if net_profit and revenue else None,
"is_total": True,
},
]
return {
"period": period,
"prev_period": prev_period,
"items": items,
}
# ============================================================
# 报表2: 预算执行报告
# ============================================================
@router.get("/budget-execution")
def get_budget_execution(
period: str = Query(None, description="格式 YYYY-MM"),
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""预算执行报告 — 各KPI预算vs实际vs差异率"""
if period is None:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
items = []
summary = {"total": 0, "with_budget": 0, "over_budget": 0, "normal": 0, "under_budget": 0}
for kpi in kpis:
dev = calc_period_deviation(db, kpi.id, period)
if dev.get("actual_value") is None and dev.get("budget_value") is None:
continue # 跳过完全无数据的KPI
summary["total"] += 1
if dev.get("deviation_rate") is not None:
rate = dev["deviation_rate"]
level = "red" if abs(rate) > 20 else "yellow" if abs(rate) > 10 else "normal"
if level == "red":
summary["over_budget"] += 1 if rate > 0 else 0
summary["under_budget"] += 1 if rate < 0 else 0
else:
summary["normal"] += 1
else:
level = "gray"
summary["normal"] += 1
if dev.get("budget_value") is not None:
summary["with_budget"] += 1
items.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"unit": kpi.unit,
"actual_value": dev.get("actual_value"),
"budget_value": dev.get("budget_value"),
"deviation_amount": dev.get("deviation_amount"),
"deviation_rate": dev.get("deviation_rate"),
"is_over_budget": dev.get("is_over_budget"),
"alert_level": level,
})
# alert_level 过滤
if alert_level:
items = [i for i in items if i["alert_level"] == alert_level]
return {"period": period, "summary": summary, "items": items}
# ============================================================
# 报表3: KPI趋势报告
# ============================================================
@router.get("/kpi-trends")
def get_kpi_trends(
kpi_id: Optional[int] = Query(None),
dimension: Optional[str] = Query(None),
months: int = Query(12, ge=3, le=36),
db: Session = Depends(get_db),
):
"""KPI趋势报告 — 选定KPI的历史趋势线"""
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.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).all()
results = []
for kpi in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id
).order_by(KPIValue.period.desc()).limit(months).all()
values.reverse()
trend = [{"period": v.period, "value": v.actual_value} for v in values]
vals = [v.actual_value for v in values if v.actual_value is not None]
target = kpi.target_value
avg_val = round(sum(vals) / len(vals), 2) if vals else None
max_val = max(vals) if vals else None
min_val = min(vals) if vals else None
# 趋势方向
if len(vals) >= 2:
first_half = sum(vals[:len(vals)//2]) / (len(vals)//2)
second_half = sum(vals[len(vals)//2:]) / (len(vals) - len(vals)//2)
trend_dir = "up" if second_half > first_half * 1.05 else "down" if second_half < first_half * 0.95 else "stable"
else:
trend_dir = "stable"
results.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"unit": kpi.unit,
"target_value": target,
"trend": trend,
"trend_dir": trend_dir,
"avg": avg_val,
"max": max_val,
"min": min_val,
})
return {"data": results}
# ============================================================
# 报表4: 四维度绩效评分卡
# ============================================================
DIM_CONFIG = {
"finance": {"name": "财务维度", "icon": "💰", "color": "#409eff"},
"customer": {"name": "客户维度", "icon": "🤝", "color": "#67c23a"},
"process": {"name": "内部流程", "icon": "⚙️", "color": "#e6a23c"},
"learning": {"name": "学习成长", "icon": "📚", "color": "#f56c6c"},
}
@router.get("/bsc-scorecard")
def get_bsc_scorecard(
map_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""四维度绩效评分卡 — BSC健康度"""
if period is None:
period = datetime.now().strftime("%Y-%m")
# 取最新的已发布地图
map_query = db.query(StrategicMap).filter(StrategicMap.status == "published")
if map_id:
map_query = map_query.filter(StrategicMap.id == map_id)
sm = map_query.order_by(StrategicMap.updated_at.desc()).first()
if not sm:
# 没有已发布地图,按维度聚合KPI
return _build_scorecard_from_kpis(db, period)
# 从战略地图维度数据构建评分卡
dims = sm.dimensions
if isinstance(dims, str):
import json
dims = json.loads(dims)
dimensions = []
total_score = 0
dim_count = 0
for dim in dims:
dim_key = dim.get("key", "")
config = DIM_CONFIG.get(dim_key, {"name": dim.get("name", dim_key), "icon": "📊", "color": "#999"})
objectives = dim.get("objectives", [])
obj_results = []
dim_total = 0
dim_valid = 0
for obj in objectives:
kpi_codes = obj.get("kpis", [])
kpi_scores = []
for code in kpi_codes:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi: continue
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period
).order_by(KPIValue.id.desc()).first()
if v and v.actual_value and kpi.target_value:
ratio = v.actual_value / kpi.target_value
score = min(round(ratio * 100, 1), 100)
level = "green" if ratio >= 0.9 else "yellow" if ratio >= 0.7 else "red"
kpi_scores.append({"code": code, "name": kpi.kpi_name, "actual": v.actual_value, "target": kpi.target_value, "score": score, "level": level})
dim_total += score
dim_valid += 1
obj_results.append({
"name": obj.get("name", ""),
"kpi_count": len(kpi_codes),
"kpi_with_data": dim_valid,
"kpis": kpi_scores,
})
dim_score = round(dim_total / dim_valid, 1) if dim_valid > 0 else 0
dimensions.append({
"key": dim_key,
"name": config["name"],
"icon": config["icon"],
"color": config["color"],
"score": dim_score,
"objectives": obj_results,
})
total_score += dim_score
dim_count += 1
overall = round(total_score / dim_count, 1) if dim_count > 0 else 0
return {
"period": period,
"map_id": sm.id,
"map_title": sm.title,
"overall_score": overall,
"dimensions": dimensions,
}
def _build_scorecard_from_kpis(db: Session, period: str) -> dict:
"""没有战略地图时,直接按维度聚合KPI算分"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
dims: dict = {}
for kpi in kpis:
dim = kpi.dimension or "other"
if dim not in dims:
dims[dim] = {"kpis": [], "total_score": 0, "valid": 0}
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period
).order_by(KPIValue.id.desc()).first()
score = None
level = "gray"
if v and v.actual_value and kpi.target_value:
ratio = v.actual_value / kpi.target_value
score = min(round(ratio * 100, 1), 100)
level = "green" if ratio >= 0.9 else "yellow" if ratio >= 0.7 else "red"
dims[dim]["total_score"] += score
dims[dim]["valid"] += 1
dims[dim]["kpis"].append({
"code": kpi.kpi_code,
"name": kpi.kpi_name,
"actual": v.actual_value if v else None,
"target": kpi.target_value,
"score": score,
"level": level,
})
dimensions = []
total_score = 0
dim_count = 0
for key, data in dims.items():
config = DIM_CONFIG.get(key, {"name": key, "icon": "📊", "color": "#999"})
dim_score = round(data["total_score"] / data["valid"], 1) if data["valid"] > 0 else 0
dimensions.append({
"key": key,
"name": config["name"],
"icon": config["icon"],
"color": config["color"],
"score": dim_score,
"objectives": [{"name": "全部KPI", "kpis": data["kpis"], "kpi_count": len(data["kpis"]), "kpi_with_data": data["valid"]}],
})
total_score += dim_score
dim_count += 1
return {
"period": period,
"map_id": None,
"map_title": None,
"overall_score": round(total_score / dim_count, 1) if dim_count > 0 else 0,
"dimensions": dimensions,
}