""" 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, Subject 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, } # ============================================================ # 利润表: 新30号准则五板块结构 (2027) # ============================================================ # 科目编码 → 新30号准则板块映射(PRD第118-145行) NEW_STANDARD_MAP = { # 经营类 "6001": "operating", # 主营业务收入 "6051": "operating", # 其他业务收入 "6401": "operating", # 主营业务成本 "6402": "operating", # 其他业务成本 "6601": "operating", # 销售费用 "6602": "operating", # 管理费用 "660204": "operating_rd", # 研发费用(从管理费剥离) "6603": "operating_fx", # 经营汇兑损益 "6701": "operating", # 经营资产减值损失 # 投资类 "6011": "investing", # 利息收入(银行存款) "6111": "investing", # 投资收益 "611101": "investing", # 股权投资 "670101": "investing", # 投资类资产减值 # 筹资类 "660301": "financing", # 利息支出(借款) "660302": "financing_fx", # 筹资汇兑损益 # 所得税 "6801": "tax", # 所得税费用 # 终止经营 "6901": "discontinued", # 终止经营损益 } # 板块 → 展示信息 BLOCK_INFO = { "operating": { "name": "一、经营类损益", "short_name": "经营类", "items": [ {"code": "6001", "name": "营业收入", "sign": 1}, {"code": "6051", "name": "其他业务收入", "sign": 1}, {"code": "6401", "name": "减:营业成本", "sign": -1}, {"code": "6402", "name": "减:其他业务成本", "sign": -1}, {"code": "6601", "name": "减:销售费用", "sign": -1}, {"code": "6602", "name": "减:管理费用", "sign": -1}, {"code": "660204", "name": "减:研发费用", "sign": -1}, {"code": "6603", "name": "经营汇兑损益", "sign": 1}, {"code": "6701", "name": "减:经营资产减值损失", "sign": -1}, ], "result_key": "operating_profit", "result_name": "经营利润", }, "investing": { "name": "二、投资类损益", "short_name": "投资类", "items": [ {"code": "6011", "name": "利息收入", "sign": 1}, {"code": "6111", "name": "投资收益", "sign": 1}, {"code": "670101", "name": "减:投资类资产减值", "sign": -1}, ], "result_key": "investing_profit", "result_name": "投资净收益", }, "financing": { "name": "三、筹资类损益", "short_name": "筹资类", "items": [ {"code": "660301", "name": "减:利息支出", "sign": -1}, {"code": "660302", "name": "筹资汇兑损益", "sign": 1}, ], "result_key": "financing_profit", "result_name": "筹资费用净额", }, "tax": { "name": "四、所得税费用", "short_name": "所得税", "items": [ {"code": "6801", "name": "减:所得税费用", "sign": -1}, ], "result_key": "tax_profit", "result_name": "所得税费用", }, "discontinued": { "name": "五、终止经营损益", "short_name": "终止经营", "items": [ {"code": "6901", "name": "终止经营损益", "sign": 1}, ], "result_key": "discontinued_profit", "result_name": "终止经营损益", }, } def _get_subject_amount(db: Session, code: str, period: str) -> Optional[float]: """从 subjects + kpi_values 获取科目金额数据""" # 尝试从KPI数据获取(KPI编码与科目编码映射) kpi_code_map = { "6001": "F_REVENUE", "6051": "F_REVENUE_OTHER", "6401": "F_COST", "6402": "F_COST_OTHER", "6601": "F_SELLING_EXP", "6602": "F_ADMIN_EXP", "660204": "F_RD_EXP", "6603": "F_FINANCE_EXP", "6701": "F_IMPAIRMENT_LOSS", "6011": "F_INTEREST_INCOME", "6111": "F_INVEST_INCOME", "611101": "F_INVEST_INCOME", "660301": "F_INTEREST_EXP", "660302": "F_FX_LOSS", "6801": "F_TAX_EXP", "6901": "F_DISCONTINUED", } # 1. 优先从 kpi_values 取 if code in kpi_code_map: kpi_code = kpi_code_map[code] kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() if kpi: 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 is not None: return float(v.actual_value) # 2. 从 subjects + voucher_details 取(如果存在) try: from app.models import VoucherDetail result = db.query( func.sum(VoucherDetail.debit_amount - VoucherDetail.credit_amount) ).filter( VoucherDetail.subject_code == code, VoucherDetail.period == period ).scalar() if result is not None: return float(result) except Exception: pass return None @router.get("/profit-statement") def get_profit_statement( period: str = Query(None, description="格式 YYYY-MM"), format: str = Query("old", description="old/new"), db: Session = Depends(get_db), ): """利润表 — 支持旧格式和新30号准则五板块格式""" if period is None: period = datetime.now().strftime("%Y-%m") if format == "old": # 旧30号准则格式(保留兼容) return get_profit_summary(period=period, db=db) # === 新30号准则:五板块结构 === blocks = [] total_net_profit = 0 all_items_have_data = True for block_key in ["operating", "investing", "financing", "tax", "discontinued"]: block_cfg = BLOCK_INFO[block_key] items = [] block_subtotal = 0 block_has_data = False for item_cfg in block_cfg["items"]: amount = _get_subject_amount(db, item_cfg["code"], period) if amount is not None: effective = amount * item_cfg["sign"] block_subtotal += effective block_has_data = True items.append({ "code": item_cfg["code"], "name": item_cfg["name"], "amount": round(amount, 2) if amount is not None else None, "sign": item_cfg["sign"], "effective": round(amount * item_cfg["sign"], 2) if amount is not None else None, }) # Fallback: 使用PRD示例数据 if not block_has_data: all_items_have_data = False block_subtotal = _get_demo_block_total(block_key) block_result = { "key": block_key, "name": block_cfg["name"], "short_name": block_cfg["short_name"], "subtotal": round(block_subtotal, 2), "subtotal_name": block_cfg["result_name"], "items": items, "expanded": True, "has_real_data": block_has_data, } blocks.append(block_result) total_net_profit += block_subtotal # 合计行:净利润 = 一二三+四+五 return { "period": period, "format": "new", "title": f"利润表 — 新30号准则({period})", "blocks": blocks, "net_profit": round(total_net_profit, 2), "net_profit_name": "净利润", "all_items_have_data": all_items_have_data, "prev_period": None, # TODO: P1追溯调整 } def _get_demo_block_total(block_key: str) -> float: """PRD示例数据 fallback""" demo = { "operating": -567883, "investing": 123456, "financing": -98765, "tax": -43210, "discontinued": 0, } return demo.get(block_key, 0) # ============================================================ # 追溯调整: 2026年数据按新30号准则重述 (P1) # ============================================================ @router.get("/restatement") def get_restatement( period: str = Query(None, description="格式 YYYY-MM"), db: Session = Depends(get_db), ): """2026年数据按新准则重述 — 旧口径vs新口径双列对比,自动标记调整项""" if period is None: period = datetime.now().strftime("%Y-%m") # 旧口径数据 (传统利润表项目) def _old_kpi_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 float(v.actual_value) if v and v.actual_value is not None else None old_revenue = _old_kpi_val("F_REVENUE") old_revenue_other = _old_kpi_val("F_REVENUE_OTHER") old_cost = _old_kpi_val("F_COST") old_cost_other = _old_kpi_val("F_COST_OTHER") old_selling_exp = _old_kpi_val("F_SELLING_EXP") old_admin_exp = _old_kpi_val("F_ADMIN_EXP") old_finance_exp = _old_kpi_val("F_FINANCE_EXP") old_rd_exp = _old_kpi_val("F_RD_EXP") # 新口径数据 (从科目映射或kpi_values获取) new_revenue = _get_subject_amount(db, "6001", period) new_revenue_other = _get_subject_amount(db, "6051", period) new_cost = _get_subject_amount(db, "6401", period) new_cost_other = _get_subject_amount(db, "6402", period) new_selling = _get_subject_amount(db, "6601", period) new_admin = _get_subject_amount(db, "6602", period) new_rd = _get_subject_amount(db, "660204", period) new_interest_income = _get_subject_amount(db, "6011", period) new_interest_exp = _get_subject_amount(db, "660301", period) new_fx = _get_subject_amount(db, "6603", period) new_fx_financing = _get_subject_amount(db, "660302", period) new_invest_income = _get_subject_amount(db, "6111", period) new_impairment = _get_subject_amount(db, "6701", period) new_invest_impairment = _get_subject_amount(db, "670101", period) new_tax = _get_subject_amount(db, "6801", period) new_discontinued = _get_subject_amount(db, "6901", period) # 旧口径汇总计算 old_operating_items = [ {"name": "营业收入", "old_value": old_revenue, "category": "operating"}, {"name": "其他业务收入", "old_value": old_revenue_other, "category": "operating"}, {"name": "减:营业成本", "old_value": old_cost, "category": "operating"}, {"name": "减:其他业务成本", "old_value": old_cost_other, "category": "operating"}, {"name": "减:销售费用", "old_value": old_selling_exp, "category": "operating"}, {"name": "减:管理费用", "old_value": old_admin_exp, "category": "operating"}, {"name": "减:财务费用", "old_value": old_finance_exp, "category": "financing"}, ] # 新口径项目明细 new_items_map = [ # 经营类 {"name": "营业收入", "new_value": new_revenue, "category": "operating"}, {"name": "其他业务收入", "new_value": new_revenue_other, "category": "operating"}, {"name": "减:营业成本", "new_value": new_cost, "category": "operating"}, {"name": "减:其他业务成本", "new_value": new_cost_other, "category": "operating"}, {"name": "减:销售费用", "new_value": new_selling, "category": "operating"}, {"name": "减:管理费用(不含研发)", "new_value": new_admin, "category": "operating"}, {"name": "减:研发费用", "new_value": new_rd, "category": "operating"}, {"name": "减:经营资产减值损失", "new_value": new_impairment, "category": "operating"}, {"name": "经营汇兑损益", "new_value": new_fx, "category": "operating"}, # 投资类 {"name": "利息收入", "new_value": new_interest_income, "category": "investing"}, {"name": "投资收益", "new_value": new_invest_income, "category": "investing"}, {"name": "减:投资类资产减值", "new_value": new_invest_impairment, "category": "investing"}, # 筹资类 {"name": "减:利息支出", "new_value": new_interest_exp, "category": "financing"}, {"name": "筹资汇兑损益", "new_value": new_fx_financing, "category": "financing"}, # 所得税 {"name": "减:所得税费用", "new_value": new_tax, "category": "tax"}, # 终止经营 {"name": "终止经营损益", "new_value": new_discontinued, "category": "discontinued"}, ] # 构建对比行 items = [] adjusted_count = 0 new_count = 0 unchanged_count = 0 # 先处理旧口径中存在的项目,匹配新口径 old_new_mapping = { "营业收入": "营业收入", "其他业务收入": "其他业务收入", "减:营业成本": "减:营业成本", "减:其他业务成本": "减:其他业务成本", "减:销售费用": "减:销售费用", "减:管理费用": "减:管理费用(不含研发)", } for old_item in old_operating_items: name = old_item["name"] old_val = old_item["old_value"] new_name = old_new_mapping.get(name, name) new_item = next((n for n in new_items_map if n["name"] == new_name), None) new_val = new_item["new_value"] if new_item else None # 管理费用:旧口径含研发,新口径不含 → 自动调整 if name == "减:管理费用": # 旧管理费 - 旧研发费 = 新管理费(不含研发) computed_new = old_val if old_admin_exp is not None and old_rd_exp is not None: # 如果新口径取不到值,用旧口径推算 if new_val is None: new_val = old_admin_exp - old_rd_exp if old_rd_exp else old_admin_exp diff = round(new_val - old_val, 2) if old_val is not None and new_val is not None else None needs_adj = diff is not None and abs(diff) > 0.01 if needs_adj: adjusted_count += 1 items.append({ "item_name": name, "old_value": old_val, "new_value": new_val, "difference": diff, "needs_adjustment": needs_adj, "adjustment_reason": "研发费用剥离" if needs_adj else None, "category": old_item["category"], }) # 自动带上研发费用行 rd_old = None # 旧口径无单独研发费用 rd_new = new_rd or old_rd_exp if rd_new is not None: adjusted_count += 1 items.append({ "item_name": "减:研发费用(单独列示)", "old_value": rd_old, "new_value": rd_new, "difference": rd_new if rd_new is not None else None, "needs_adjustment": rd_new is not None, "adjustment_reason": "新30号准则单独列示", "category": "operating", }) continue # 财务费用:旧口径一行汇总,新口径拆解为利息收入(投资类)+利息支出(筹资类) if name == "减:财务费用": fin_old = old_val fin_new_total = 0 fin_new_breakdown = [] # 利息支出(筹资类) int_exp_val = new_interest_exp if int_exp_val is not None: fin_new_total += -int_exp_val fin_new_breakdown.append({ "item_name": "减:利息支出(筹资类)", "old_value": None, "new_value": int_exp_val, "difference": None, "needs_adjustment": True, "adjustment_reason": "财务费用拆解", "category": "financing", }) adjusted_count += 1 # 筹资汇兑损益 fx_fin_val = new_fx_financing if fx_fin_val is not None: fin_new_total += fx_fin_val fin_new_breakdown.append({ "item_name": "筹资汇兑损益", "old_value": None, "new_value": fx_fin_val, "difference": None, "needs_adjustment": True, "adjustment_reason": "财务费用拆解", "category": "financing", }) adjusted_count += 1 # 利息收入(投资类) int_inc_val = new_interest_income if int_inc_val is not None: fin_new_breakdown.append({ "item_name": "利息收入(投资类)", "old_value": None, "new_value": int_inc_val, "difference": None, "needs_adjustment": True, "adjustment_reason": "财务费用拆解", "category": "investing", }) adjusted_count += 1 # 经营汇兑损益(经营类) fx_op_val = new_fx if fx_op_val is not None: fin_new_breakdown.append({ "item_name": "经营汇兑损益(经营类)", "old_value": None, "new_value": fx_op_val, "difference": None, "needs_adjustment": True, "adjustment_reason": "财务费用拆解", "category": "operating", }) adjusted_count += 1 items.append({ "item_name": "减:财务费用", "old_value": fin_old, "new_value": None, "difference": None, "needs_adjustment": True, "adjustment_reason": "财务费用拆解为投资类利息收入和筹资类利息支出", "category": "financing", "breakdown": fin_new_breakdown, }) # 把拆解项加到主列表 for brk in fin_new_breakdown: items.append(brk) continue # 普通项目直接对比 diff = round(new_val - old_val, 2) if old_val is not None and new_val is not None else None needs_adj = diff is not None and abs(diff) > 0.01 if needs_adj: adjusted_count += 1 else: unchanged_count += 1 items.append({ "item_name": name, "old_value": old_val, "new_value": new_val, "difference": diff, "needs_adjustment": needs_adj, "adjustment_reason": None, "category": old_item["category"], }) # 新增项目(只有新口径有) existing_names = [i["item_name"] for i in items] for new_item in new_items_map: if new_item["name"] not in existing_names and new_item["new_value"] is not None: items.append({ "item_name": new_item["name"], "old_value": None, "new_value": new_item["new_value"], "difference": None, "needs_adjustment": True, "adjustment_reason": "新30号准则新增项目", "category": new_item["category"], }) new_count += 1 adjusted_count += 1 # 计算旧口径净利润和新口径净利润 def _calc_net_profit_old(): """旧口径净利润 (简化)""" rev = old_revenue or 0 rev_other = old_revenue_other or 0 c = old_cost or 0 c_other = old_cost_other or 0 sell = old_selling_exp or 0 admin = old_admin_exp or 0 fin = old_finance_exp or 0 return rev + rev_other - c - c_other - sell - admin - fin def _calc_net_profit_new(): """新口径净利润 = 经营利润 + 投资净收益 + 筹资净费用 + 所得税 + 终止经营""" op_items = ["6001", "6051", "6401", "6402", "6601", "6602", "660204", "6603", "6701"] inv_items = ["6011", "6111", "670101"] fin_items = ["660301", "660302"] tax_items = ["6801"] dis_items = ["6901"] total = 0 for codes in [op_items, inv_items, fin_items, tax_items, dis_items]: for code in codes: amt = _get_subject_amount(db, code, period) if amt is not None: # 根据BLOCK_INFO中的sign处理 for bk in BLOCK_INFO.values(): for ic in bk["items"]: if ic["code"] == code: total += amt * ic["sign"] return round(total, 2) old_net = _calc_net_profit_old() new_net = _calc_net_profit_new() return { "period": period, "items": items, "summary": { "total_items": len(items), "adjusted_items": adjusted_count, "new_items": new_count, "unchanged_items": unchanged_count, }, "net_profit_comparison": { "old_net_profit": round(old_net, 2), "new_net_profit": new_net, "difference": round(new_net - old_net, 2), }, } @router.get("/category-map") def get_category_map( db: Session = Depends(get_db), ): """返回科目→新30号准则板块映射""" subjects_data = db.query(Subject).filter(Subject.is_active == 1).order_by(Subject.subject_code).all() map_list = [] for s in subjects_data: if s.new_standard_category: map_list.append({ "subject_code": s.subject_code, "subject_name": s.subject_name, "category": s.new_standard_category, }) # 如果没有数据库数据,返回硬编码映射 if not map_list: # 从 NEW_STANDARD_MAP 反向构造 all_subjects = db.query(Subject).filter(Subject.is_active == 1).all() subj_map = {s.subject_code: s.subject_name for s in all_subjects} for code, cat in NEW_STANDARD_MAP.items(): map_list.append({ "subject_code": code, "subject_name": subj_map.get(code, code), "category": cat, }) # 按板块分组 grouped = {"operating": [], "operating_rd": [], "operating_fx": [], "investing": [], "financing": [], "financing_fx": [], "tax": [], "discontinued": []} for m in map_list: cat = m["category"] if cat in grouped: grouped[cat].append(m) else: grouped.setdefault(cat, []).append(m) return { "mapping": NEW_STANDARD_MAP, "subjects": map_list, "grouped": grouped, "total": len(map_list), } @router.get("/dupont") def get_dupont_analysis( entity: str = Query("bohai"), db: Session = Depends(get_db), ): """杜邦分析 — ROE三级拆解 (CMA P2)""" if entity == "bohai": net_profit = 14.1 # 万 revenue = 383 # 万 total_assets = 533 # 万 equity = 114 # 万 net_profit_margin = round(net_profit / revenue * 100, 2) asset_turnover = round(revenue / total_assets, 4) financial_leverage = round(total_assets / equity, 2) roe = round(net_profit_margin / 100 * asset_turnover * financial_leverage * 100, 2) # 上期对比(模拟上一期数据) prev_roe = round(11.2, 2) roe_change = round(roe - prev_roe, 2) return { "entity": "bohai", "entity_name": "陕西博海科技(IT服务)", "period": "2026年H1", "roe": roe, "roe_change": roe_change, "roe_trend": "up" if roe_change > 0 else "down", "prev_roe": prev_roe, "factors": { "net_profit_margin": { "value": net_profit_margin, "label": "净利润率", "desc": "净利润/收入", "status": "🟡" if net_profit_margin < 5 else "✅", "assessment": "IT经销行业正常偏低", "raw": {"net_profit": net_profit, "revenue": revenue}, }, "asset_turnover": { "value": asset_turnover, "label": "资产周转率", "desc": "收入/总资产", "status": "🟡" if asset_turnover < 1 else "✅", "assessment": "资金效率中等", "raw": {"revenue": revenue, "total_assets": total_assets}, }, "financial_leverage": { "value": financial_leverage, "label": "财务杠杆", "desc": "总资产/净资产", "status": "🟡" if financial_leverage > 3 else "✅", "assessment": "负债率78.6%,偏高但可控", "raw": {"total_assets": total_assets, "equity": equity}, }, }, "raw_data": { "net_profit": net_profit, "revenue": revenue, "total_assets": total_assets, "equity": equity, }, "insight": { "improvement": "提高周转率或利润率,而非加杠杆", "detail": f"净利润率{net_profit_margin}%偏低,资产周转率{asset_turnover}x中等,财务杠杆{financial_leverage}x偏高。改善方向:提升毛利率或加快库存周转。", }, } return {"error": "不支持的实体"}