Files
cma-management/backend/app/api/reports.py
T
Hermes CI Fix ec6af751a5 feat(new30-p1): 追溯调整数据准备+KPI口径更新
- 后端新增 GET /api/cma/reports/restatement 端点
  - 旧口径vs新口径双列对比
  - 自动标记需调整项(管理费用剥离研发费用、财务费用拆解)
  - 返回净利润对比汇总
- 前端报表中心新增"追溯调整"Tab
  - 双列表格展示(旧准则/新准则)
  - 差异自动高亮标记
  - 调整原因列、汇总统计
- KPI口径更新(entity_id=1)
  - F_COST_RATIO 费用率: (销售费用+管理费用-研发费用)/营业收入×100%, target=15%
  - 新增 F_OP_PROFIT_MARGIN 经营利润率: 经营利润/营业收入×100%, target=5%
- 构建部署验证通过
2026-07-22 00:01:20 +08:00

1080 lines
41 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, 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": "不支持的实体"}