""" 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, }