Files
cma-management/backend/app/api/reports.py
T

423 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
}