767 lines
28 KiB
Python
767 lines
28 KiB
Python
"""
|
||
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)
|
||
|
||
|
||
@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": "不支持的实体"}
|