Files
cma-management/backend/app/api/reports.py
T
Hermes CI Fix 7b5795bf07 fix(P0): 杜邦分析接口去除硬编码错误数据 — equity 114万→358.4万、ROE 12.38%→3.95%、新增hanke分支读DB(KPI 2026H1 verified)
- 新增 _get_dupont_kpi 辅助函数: 按 entity_id+kpi_code 查 kpi_definitions→kpi_values(period=2026H1, verified)
- bohai: 净权益修正为358.4万(来源 bohai_comprehensive_analysis_2026.md 试算平衡表),财务杠杆4.68x→1.49x,负债率78.6%→32.8%结构健康,ROE≈3.94%,删除误导性加杠杆建议
- hanke: 新增分支,从DB读 F_REVENUE=713.27/F_NET_PROFIT=-90.86,净利率-12.74%,资产/权益缺失时factor返回null标注数据待补充,prev_roe返回null(前端兼容显示无对比),接口返回200
2026-08-15 23:02:48 +08:00

2712 lines
106 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
import json
from pydantic import BaseModel
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, ActionPlan, OperationLog, ReportHistory
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/dual"),
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)
if format == "dual":
# 双列对比:旧准则 vs 新准则
old_data = get_profit_summary(period=period, db=db)
new_data = _build_new_format_profit(db, period)
return {
"period": period,
"format": "dual",
"title": f"利润表双列对比({period}",
"old_format": old_data,
"new_format": new_data,
}
# === 新30号准则:五板块结构 ===
return _build_new_format_profit(db, period)
def _build_new_format_profit(db: Session, period: str) -> dict:
"""构建新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
# 附注明细(对外法定报表披露要求)
notes = _build_profit_notes(db, period, blocks, total_net_profit)
# 合计行:净利润 = 一二三+四+五
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,
"notes": notes,
"prev_period": None, # TODO: P1追溯调整
}
def _build_profit_notes(db: Session, period: str, blocks: list, net_profit: float) -> dict:
"""利润表附注明细 — 收入/费用/财务费用拆解 + 板块勾稽 + 关键比率"""
def amt(code):
return _get_subject_amount(db, code, period)
revenue_main = amt("6001")
revenue_other = amt("6051")
revenue_total = None
if revenue_main is not None or revenue_other is not None:
revenue_total = round((revenue_main or 0) + (revenue_other or 0), 2)
revenue_breakdown = [
{"name": "主营业务收入", "code": "6001", "amount": round(revenue_main, 2) if revenue_main is not None else None},
{"name": "其他业务收入", "code": "6051", "amount": round(revenue_other, 2) if revenue_other is not None else None},
]
expense_items = [
{"name": "营业成本", "code": "6401", "amount": amt("6401")},
{"name": "其他业务成本", "code": "6402", "amount": amt("6402")},
{"name": "销售费用", "code": "6601", "amount": amt("6601")},
{"name": "管理费用", "code": "6602", "amount": amt("6602")},
{"name": "研发费用", "code": "660204", "amount": amt("660204")},
{"name": "经营资产减值损失", "code": "6701", "amount": amt("6701")},
]
expense_breakdown = [
{"name": e["name"], "code": e["code"], "amount": round(e["amount"], 2) if e["amount"] is not None else None}
for e in expense_items
]
finance_breakdown = [
{"name": "利息收入(投资类)", "code": "6011", "amount": amt("6011")},
{"name": "投资收益(投资类)", "code": "6111", "amount": amt("6111")},
{"name": "利息支出(筹资类)", "code": "660301", "amount": amt("660301")},
{"name": "经营汇兑损益", "code": "6603", "amount": amt("6603")},
{"name": "筹资汇兑损益", "code": "660302", "amount": amt("660302")},
]
finance_breakdown = [
{"name": f["name"], "code": f["code"], "amount": round(f["amount"], 2) if f["amount"] is not None else None}
for f in finance_breakdown
]
# 板块勾稽(净利润 = 五板块之和)
block_reconciliation = [
{"name": b["name"], "key": b["key"], "amount": b["subtotal"], "result_name": b["subtotal_name"]}
for b in blocks
]
# 关键比率
key_ratios = []
if revenue_total:
key_ratios.append({
"name": "毛利率",
"value": round((revenue_total - (amt("6401") or 0) - (amt("6402") or 0)) / revenue_total * 100, 2) if (amt("6401") is not None or amt("6402") is not None) else None,
})
key_ratios.append({
"name": "净利率",
"value": round(net_profit / revenue_total * 100, 2),
})
rd = amt("660204")
if rd is not None:
key_ratios.append({"name": "研发费用率", "value": round(rd / revenue_total * 100, 2)})
else:
key_ratios.append({"name": "毛利率", "value": None})
key_ratios.append({"name": "净利率", "value": None})
return {
"revenue_total": round(revenue_total, 2) if revenue_total is not None else None,
"revenue_breakdown": revenue_breakdown,
"expense_breakdown": expense_breakdown,
"finance_breakdown": finance_breakdown,
"block_reconciliation": block_reconciliation,
"net_profit": round(net_profit, 2),
"key_ratios": key_ratios,
}
# ============================================================
# MPM管理层指标计算器 (P2)
# ============================================================
# MPM指标类型定义
MPM_INDICATOR_TYPES = {
"ebitda": {
"name": "EBITDA",
"description": "息税折旧摊销前利润",
"base_label": "净利润",
"default_adjustments": [
{"code": "tax", "name": "加:所得税费用", "sign": 1, "checked": True},
{"code": "interest", "name": "加:利息支出", "sign": 1, "checked": True},
{"code": "depreciation", "name": "加:折旧与摊销", "sign": 1, "checked": True},
{"code": "impairment", "name": "加:资产减值损失", "sign": 1, "checked": False},
],
},
"adjusted_net_profit": {
"name": "调整后净利润",
"description": "剔除非经常性项目后的可持续净利润",
"base_label": "净利润(准则)",
"default_adjustments": [
{"code": "impairment", "name": "加:资产减值损失", "sign": 1, "checked": True},
{"code": "equity_incentive", "name": "加:股权激励费用", "sign": 1, "checked": True},
{"code": "ma_cost", "name": "加:并购相关费用", "sign": 1, "checked": False},
{"code": "nonrecurring_income", "name": "减:非经常性投资收益", "sign": -1, "checked": True},
{"code": "asset_disposal", "name": "减:资产处置收益", "sign": -1, "checked": False},
{"code": "government_grant", "name": "减:政府补助", "sign": -1, "checked": False},
],
},
"free_cash_flow": {
"name": "自由现金流",
"description": "经营现金流扣除资本支出后的可自由支配现金流",
"base_label": "经营现金流",
"default_adjustments": [
{"code": "capex", "name": "减:资本支出", "sign": -1, "checked": True},
{"code": "working_capital", "name": "减:营运资本增加", "sign": -1, "checked": True},
{"code": "maintenance_capex", "name": "减:维护性资本支出", "sign": -1, "checked": False},
{"code": "dividend", "name": "加:股息收入", "sign": 1, "checked": False},
],
},
"custom": {
"name": "自定义指标",
"description": "自定义管理层指标",
"base_label": "净利润",
"default_adjustments": [
{"code": "adjustment_1", "name": "调整项目1", "sign": 1, "checked": False, "amount": None},
{"code": "adjustment_2", "name": "调整项目2", "sign": -1, "checked": False, "amount": None},
{"code": "adjustment_3", "name": "调整项目3", "sign": 1, "checked": False, "amount": None},
],
},
}
# 调整项默认金额(从利润表自动取值映射)
ADJUSTMENT_VALUE_MAP = {
"tax": {"code": "6801", "sign_inverse": True}, # 所得税费用,加回需取绝对值
"interest": {"code": "660301", "sign_inverse": True},
"impairment": {"code": "6701", "sign_inverse": False}, # 资产减值损失本身是费用
"equity_incentive": None, # 无映射,需用户输入
"ma_cost": None,
"nonrecurring_income": {"code": "6111", "sign_inverse": False},
"asset_disposal": None,
"government_grant": None,
"capex": None,
"working_capital": None,
"maintenance_capex": None,
"dividend": None,
}
class MpmCalculateRequest(BaseModel):
indicator_type: str = "adjusted_net_profit"
period: str = None
adjustments: list[dict] = None # [{code, name, sign, checked, amount}]
@router.post("/mpm-calculate")
def mpm_calculate(
req: MpmCalculateRequest,
db: Session = Depends(get_db),
):
"""MPM管理层指标计算器 — 生成合规调节表"""
if req.period is None:
req.period = datetime.now().strftime("%Y-%m")
indicator_cfg = MPM_INDICATOR_TYPES.get(req.indicator_type)
if not indicator_cfg:
raise HTTPException(status_code=400, detail=f"不支持的指标类型: {req.indicator_type}")
# 获取基准值:净利润
net_profit = _calc_new_net_profit(db, req.period)
if net_profit is None:
net_profit = 0
# 经营现金流(自由现金流的基准)
operating_cf = _get_kpi_val(db, "F_OPERATING_CF", req.period)
# 确定基准值
if req.indicator_type == "free_cash_flow":
base_value = operating_cf or net_profit # fallback
base_label = "经营现金流"
else:
base_value = net_profit
base_label = indicator_cfg["base_label"]
# 获取调整项(来自请求或默认)
adjustments = req.adjustments if req.adjustments else indicator_cfg["default_adjustments"]
# 自动填充调整项金额
reconciliation_items = []
running_total = base_value
# 第一步:基准值
reconciliation_items.append({
"step": 0,
"code": "_base",
"name": base_label,
"sign": 1,
"amount": round(base_value, 2),
"effective": round(base_value, 2),
"is_base": True,
"running_total": round(base_value, 2),
})
for adj in adjustments:
code = adj.get("code", "")
checked = adj.get("checked", False)
sign = adj.get("sign", 1)
name = adj.get("name", "")
amount = adj.get("amount")
# 尝试自动取值
if amount is None and checked:
amount = _get_adjustment_value(db, code, req.period)
effective = round(amount * sign, 2) if amount is not None else None
item = {
"step": len(reconciliation_items),
"code": code,
"name": name,
"sign": sign,
"amount": round(amount, 2) if amount is not None else None,
"effective": effective,
"checked": checked,
"is_base": False,
"running_total": None,
}
if checked and effective is not None:
running_total += effective
item["running_total"] = round(running_total, 2)
reconciliation_items.append(item)
# 最终结果
final_value = round(running_total, 2)
return {
"indicator_type": req.indicator_type,
"indicator_name": indicator_cfg["name"],
"indicator_desc": indicator_cfg["description"],
"period": req.period,
"base_value": round(base_value, 2),
"base_label": base_label,
"final_value": final_value,
"final_label": indicator_cfg["name"],
"adjustment_count": sum(1 for a in adjustments if a.get("checked", False)),
"total_adjustments": len(adjustments),
"reconciliation_items": reconciliation_items,
"has_real_data": net_profit != 0,
}
def _get_kpi_val(db: Session, code: str, period: str) -> Optional[float]:
"""从KPI定义+值获取数值"""
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
def _calc_new_net_profit(db: Session, period: str) -> Optional[float]:
"""计算新30号准则下的净利润"""
total = 0
has_data = False
for block_key in ["operating", "investing", "financing", "tax", "discontinued"]:
block_cfg = BLOCK_INFO[block_key]
for item_cfg in block_cfg["items"]:
amount = _get_subject_amount(db, item_cfg["code"], period)
if amount is not None:
total += amount * item_cfg["sign"]
has_data = True
if not has_data:
return None
return round(total, 2)
def _get_adjustment_value(db: Session, adj_code: str, period: str) -> Optional[float]:
"""获取调整项的自动取值"""
mapping = ADJUSTMENT_VALUE_MAP.get(adj_code)
if mapping is None:
return None # 需要用户输入
code = mapping["code"]
amount = _get_subject_amount(db, code, period)
if amount is None:
return None
# sign_inverse: 如果调整项是"加回"费用,费用本身的sign是负的(在利润表中是减项)
# 但在MPM调节中,加回费用取绝对值
return abs(amount) if mapping.get("sign_inverse", False) else amount
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),
}
# ============================================================
# 对外法定报表 — 新30号准则适配 (2027)
# 利润表(五板块+附注) / 资产负债表(新准则科目分类) / 现金流量表(三活动)
# ============================================================
# 资产负债表行项目: (编码列表[(code, sign)], 名称, 板块key, 新准则分类, 是否合计行)
# 新准则分类: operating经营 / investing投资 / financing筹资 / equity权益
BALANCE_SHEET_SECTIONS = [
{
"key": "current_assets",
"name": "流动资产",
"category_label": "经营资产",
"lines": [
{"codes": [("1001", 1), ("1002", 1), ("1012", 1)], "name": "货币资金", "ns_category": "operating"},
{"codes": [("1101", 1)], "name": "交易性金融资产", "ns_category": "investing"},
{"codes": [("1122", 1)], "name": "应收账款", "ns_category": "operating"},
{"codes": [("1123", 1)], "name": "预付账款", "ns_category": "operating"},
{"codes": [("1131", 1)], "name": "应收股利", "ns_category": "investing"},
{"codes": [("1221", 1)], "name": "其他应收款", "ns_category": "operating"},
{"codes": [("1405", 1)], "name": "存货", "ns_category": "operating"},
],
},
{
"key": "non_current_assets",
"name": "非流动资产",
"category_label": "投资资产",
"lines": [
{"codes": [("1511", 1)], "name": "长期股权投资", "ns_category": "investing"},
{"codes": [("1501", 1)], "name": "持有至到期投资", "ns_category": "investing"},
{"codes": [("1601", 1), ("1602", -1)], "name": "固定资产净额", "ns_category": "operating"},
{"codes": [("1701", 1)], "name": "无形资产", "ns_category": "operating"},
],
},
{
"key": "current_liabilities",
"name": "流动负债",
"category_label": "经营负债",
"lines": [
{"codes": [("2001", 1)], "name": "短期借款", "ns_category": "financing"},
{"codes": [("2202", 1)], "name": "应付账款", "ns_category": "operating"},
{"codes": [("2203", 1)], "name": "预收账款", "ns_category": "operating"},
{"codes": [("2211", 1)], "name": "应付职工薪酬", "ns_category": "operating"},
{"codes": [("2221", 1)], "name": "应交税费", "ns_category": "operating"},
{"codes": [("2241", 1)], "name": "其他应付款", "ns_category": "operating"},
],
},
{
"key": "non_current_liabilities",
"name": "非流动负债",
"category_label": "筹资负债",
"lines": [
{"codes": [("2501", 1)], "name": "长期借款", "ns_category": "financing"},
{"codes": [("2502", 1)], "name": "应付债券", "ns_category": "financing"},
],
},
{
"key": "equity",
"name": "所有者权益",
"category_label": "所有者权益",
"lines": [
{"codes": [("4001", 1)], "name": "实收资本", "ns_category": "equity"},
{"codes": [("4002", 1)], "name": "资本公积", "ns_category": "equity"},
{"codes": [("4103", 1), ("4104", 1)], "name": "未分配利润", "ns_category": "equity"},
],
},
]
# 资产负债表示例数据(博海科技, 期末/期初, 单位: 千元)
BS_DEMO = {
"1001|1002|1012": {"end": 850, "begin": 780},
"1101": {"end": 250, "begin": 220},
"1122": {"end": 1100, "begin": 1050},
"1123": {"end": 180, "begin": 160},
"1131": {"end": 120, "begin": 100},
"1221": {"end": 90, "begin": 80},
"1405": {"end": 930, "begin": 900},
"1511": {"end": 580, "begin": 550},
"1501": {"end": 200, "begin": 180},
"1601|1602": {"end": 1220, "begin": 1150},
"1701": {"end": 130, "begin": 120},
"2001": {"end": 1500, "begin": 1300},
"2202": {"end": 1050, "begin": 980},
"2203": {"end": 280, "begin": 250},
"2211": {"end": 140, "begin": 130},
"2221": {"end": 90, "begin": 85},
"2241": {"end": 60, "begin": 50},
"2501": {"end": 800, "begin": 750},
"2502": {"end": 270, "begin": 250},
"4001": {"end": 500, "begin": 500},
"4002": {"end": 180, "begin": 175},
"4103|4104": {"end": 780, "begin": 820},
}
BS_CATEGORY_CN = {
"operating": "经营类",
"investing": "投资类",
"financing": "筹资类",
"equity": "权益类",
}
def _prev_period_str(period: str) -> str:
"""上一期间 YYYY-MM → YYYY-(MM-1)"""
try:
y, m = period.split("-")
y, m = int(y), int(m)
m -= 1
if m <= 0:
m += 12
y -= 1
return f"{y}-{m:02d}"
except Exception:
return period
def _get_bs_amount(db: Session, codes: list, period: str) -> Optional[float]:
"""资产负债表科目余额 — 优先凭证明细,无数据返回 None"""
total = 0.0
has_data = False
try:
from app.models import VoucherDetail
for code, sign in codes:
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:
total += float(result) * sign
has_data = True
except Exception:
pass
return round(total, 2) if has_data else None
def _bs_line_amount(db: Session, line: dict, period: str, column: str = "end") -> dict:
"""单行:凭证数据优先,否则示例数据(column: end期末 / begin期初)"""
real = _get_bs_amount(db, line["codes"], period)
if real is not None:
return {"value": real, "is_demo": False}
key = "|".join(c for c, _ in line["codes"])
demo = BS_DEMO.get(key)
if demo:
return {"value": demo.get(column, demo["end"]), "is_demo": True}
return {"value": None, "is_demo": True}
@router.get("/balance-sheet")
def get_balance_sheet(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
):
"""资产负债表 — 新30号准则科目分类(经营/投资/筹资),期末vs期初"""
if period is None:
period = datetime.now().strftime("%Y-%m")
prev_period = _prev_period_str(period)
sections = []
total_assets_end = total_assets_begin = 0
total_liab_end = total_liab_begin = 0
total_equity_end = total_equity_begin = 0
all_real = True
for sec in BALANCE_SHEET_SECTIONS:
lines = []
sec_end = sec_begin = 0.0
sec_real = False
for line in sec["lines"]:
end = _bs_line_amount(db, line, period, column="end")
begin = _bs_line_amount(db, line, prev_period, column="begin")
if end["is_demo"] or begin["is_demo"]:
all_real = False
if end["value"] is not None:
sec_end += end["value"]
sec_real = True
if begin["value"] is not None:
sec_begin += begin["value"]
lines.append({
"name": line["name"],
"ns_category": line["ns_category"],
"ns_category_label": BS_CATEGORY_CN.get(line["ns_category"], line["ns_category"]),
"end_value": round(end["value"], 2) if end["value"] is not None else None,
"begin_value": round(begin["value"], 2) if begin["value"] is not None else None,
"is_demo": end["is_demo"] or begin["is_demo"],
})
# 归属汇总
if sec["key"] in ("current_assets", "non_current_assets"):
total_assets_end += sec_end
total_assets_begin += sec_begin
elif sec["key"] in ("current_liabilities", "non_current_liabilities"):
total_liab_end += sec_end
total_liab_begin += sec_begin
else:
total_equity_end += sec_end
total_equity_begin += sec_begin
sections.append({
"key": sec["key"],
"name": sec["name"],
"category_label": sec["category_label"],
"subtotal_end": round(sec_end, 2),
"subtotal_begin": round(sec_begin, 2),
"lines": lines,
"has_real_data": sec_real,
})
return {
"period": period,
"prev_period": prev_period,
"title": f"资产负债表 — 新30号准则({period}",
"sections": sections,
"totals": {
"assets": {"end": round(total_assets_end, 2), "begin": round(total_assets_begin, 2)},
"liabilities": {"end": round(total_liab_end, 2), "begin": round(total_liab_begin, 2)},
"equity": {"end": round(total_equity_end, 2), "begin": round(total_equity_begin, 2)},
"liab_equity": {"end": round(total_liab_end + total_equity_end, 2),
"begin": round(total_liab_begin + total_equity_begin, 2)},
"balanced": abs(total_assets_end - total_liab_end - total_equity_end) < 0.01
and abs(total_assets_begin - total_liab_begin - total_equity_begin) < 0.01,
},
"all_items_have_data": all_real,
}
# 现金流量表行项目: (code, 名称, 板块, 方向, kpi_code可选)
CASH_FLOW_LINES = [
# 经营活动
{"code": "CF01", "name": "销售商品、提供劳务收到的现金", "section": "operating", "sign": 1, "kpi_code": None},
{"code": "CF02", "name": "收到的税费返还", "section": "operating", "sign": 1, "kpi_code": None},
{"code": "CF03", "name": "收到其他与经营活动有关的现金", "section": "operating", "sign": 1, "kpi_code": None},
{"code": "CF04", "name": "购买商品、接受劳务支付的现金", "section": "operating", "sign": -1, "kpi_code": None},
{"code": "CF05", "name": "支付给职工以及为职工支付的现金", "section": "operating", "sign": -1, "kpi_code": None},
{"code": "CF06", "name": "支付的各项税费", "section": "operating", "sign": -1, "kpi_code": None},
{"code": "CF07", "name": "支付其他与经营活动有关的现金", "section": "operating", "sign": -1, "kpi_code": None},
# 投资活动
{"code": "CF08", "name": "收回投资收到的现金", "section": "investing", "sign": 1, "kpi_code": None},
{"code": "CF09", "name": "取得投资收益收到的现金", "section": "investing", "sign": 1, "kpi_code": None},
{"code": "CF10", "name": "处置固定资产、无形资产等收回的现金", "section": "investing", "sign": 1, "kpi_code": None},
{"code": "CF11", "name": "购建固定资产、无形资产等支付的现金", "section": "investing", "sign": -1, "kpi_code": None},
{"code": "CF12", "name": "投资支付的现金", "section": "investing", "sign": -1, "kpi_code": None},
# 筹资活动
{"code": "CF13", "name": "吸收投资收到的现金", "section": "financing", "sign": 1, "kpi_code": None},
{"code": "CF14", "name": "取得借款收到的现金", "section": "financing", "sign": 1, "kpi_code": None},
{"code": "CF15", "name": "偿还债务支付的现金", "section": "financing", "sign": -1, "kpi_code": None},
{"code": "CF16", "name": "分配股利、利润或偿付利息支付的现金", "section": "financing", "sign": -1, "kpi_code": None},
]
# 现金流量表示例数据(2026-06, 单位: 千元, 与利润表/KPI口径一致)
CF_DEMO = {
"CF01": 5200, "CF02": 0, "CF03": 120, "CF04": -3150, "CF05": -820,
"CF06": -360, "CF07": -140,
"CF08": 200, "CF09": 50, "CF10": 30, "CF11": -330, "CF12": -100,
"CF13": 0, "CF14": 500, "CF15": -200, "CF16": -150,
}
CF_DEMO_FX = 0 # 汇率变动对现金的影响
CF_DEMO_BEGIN = 1200 # 期初现金及现金等价物余额
def _get_cf_amount(db: Session, line: dict, period: str) -> dict:
"""现金流量表行项目 — 优先KPI/凭证,否则示例数据"""
# 经营净额行特殊处理:优先取 F_OP_CFLOW
if line.get("kpi_code"):
kpi_val = _get_kpi_val(db, line["kpi_code"], period)
if kpi_val is not None:
return {"value": round(kpi_val, 2), "is_demo": False}
real = _get_bs_amount(db, [(line["code"], line["sign"])], period)
if real is not None:
return {"value": real, "is_demo": False}
demo = CF_DEMO.get(line["code"])
if demo is not None:
return {"value": float(demo), "is_demo": True}
return {"value": None, "is_demo": True}
@router.get("/cash-flow")
def get_cash_flow_statement(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
):
"""现金流量表 — 经营/投资/筹资三活动(新30号准则直接法)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
section_cfg = [
{"key": "operating", "name": "一、经营活动产生的现金流量", "short": "经营活动"},
{"key": "investing", "name": "二、投资活动产生的现金流量", "short": "投资活动"},
{"key": "financing", "name": "三、筹资活动产生的现金流量", "short": "筹资活动"},
]
sections = []
net_by_section = {}
all_real = True
for sc in section_cfg:
lines = []
subtotal = 0.0
sec_real = False
for line in CASH_FLOW_LINES:
if line["section"] != sc["key"]:
continue
v = _get_cf_amount(db, line, period)
if v["is_demo"]:
all_real = False
if v["value"] is not None:
subtotal += v["value"]
sec_real = True
lines.append({
"code": line["code"],
"name": line["name"],
"value": round(v["value"], 2) if v["value"] is not None else None,
"is_demo": v["is_demo"],
})
net_by_section[sc["key"]] = round(subtotal, 2)
sections.append({
"key": sc["key"],
"name": sc["name"],
"short": sc["short"],
"net": round(subtotal, 2),
"lines": lines,
"has_real_data": sec_real,
})
# 经营净额行优先取 KPI F_OP_CFLOW(真实数据优先)
op_kpi = _get_kpi_val(db, "F_OP_CFLOW", period)
if op_kpi is not None:
sections[0]["net"] = round(op_kpi, 2)
net_by_section["operating"] = round(op_kpi, 2)
fx = _get_kpi_val(db, "F_FX_LOSS", period)
if fx is None:
fx = CF_DEMO_FX
fx_demo = True
else:
fx_demo = False
begin_cash = CF_DEMO_BEGIN
net_increase = round(net_by_section.get("operating", 0)
+ net_by_section.get("investing", 0)
+ net_by_section.get("financing", 0)
+ float(fx or 0), 2)
end_cash = round(begin_cash + net_increase, 2)
return {
"period": period,
"title": f"现金流量表 — 新30号准则({period}",
"sections": sections,
"fx_effect": {"name": "四、汇率变动对现金及现金等价物的影响", "value": round(float(fx), 2), "is_demo": fx_demo},
"summary": {
"net_increase": net_increase,
"begin_cash": begin_cash,
"end_cash": end_cash,
},
"all_items_have_data": all_real,
}
@router.get("/statutory")
def get_statutory_reports(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
):
"""对外法定报表(新30号准则)— 利润表+资产负债表+现金流量表 组合视图"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return {
"period": period,
"title": f"对外法定报表 — 新30号准则({period}",
"profit": _build_new_format_profit(db, period),
"balance_sheet": get_balance_sheet(period=period, db=db),
"cash_flow": get_cash_flow_statement(period=period, db=db),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
@router.get("/statutory/export")
def export_statutory_reports(
period: str = Query(None, description="格式 YYYY-MM"),
db: Session = Depends(get_db),
):
"""导出对外法定报表(新30号准则)— Excel 三表合一"""
if period is None:
period = datetime.now().strftime("%Y-%m")
data = get_statutory_reports(period=period, db=db)
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from fastapi.responses import StreamingResponse
wb = Workbook()
head_fill = PatternFill("solid", fgColor="305496")
head_font = Font(color="FFFFFF", bold=True, size=12)
section_fill = PatternFill("solid", fgColor="D9E1F2")
section_font = Font(bold=True, size=11)
total_fill = PatternFill("solid", fgColor="FCE4D6")
total_font = Font(bold=True, size=11)
thin = Side(style="thin", color="BFBFBF")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
demo_font = Font(color="D46B08", size=9, italic=True)
def style_header(ws, row, ncols, title):
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=ncols)
c = ws.cell(row=row, column=1, value=title)
c.font = Font(bold=True, size=14, color="305496")
c.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[row].height = 26
def write_row(ws, r, values, font=None, fill=None, use_border=True):
for ci, v in enumerate(values, start=1):
c = ws.cell(row=r, column=ci, value=v)
if font:
c.font = font
if fill:
c.fill = fill
if use_border:
c.border = border
return r + 1
# ── Sheet 1: 利润表(五板块+附注) ──
ws1 = wb.active
ws1.title = "利润表-新30号准则"
style_header(ws1, 1, 4, f"利润表(新30号准则五板块) {period} 单位: 元")
r = write_row(ws1, 2, ["板块", "项目", "科目编码", "本期金额"], head_font, head_fill)
profit = data["profit"]
for block in profit.get("blocks", []):
r = write_row(ws1, r, [block["name"], block.get("subtotal_name", ""), "", block["subtotal"]],
section_font, section_fill)
for item in block.get("items", []):
r = write_row(ws1, r, ["", item["name"], item["code"], item["effective"]])
r = write_row(ws1, r, ["合计", "净利润", "", profit.get("net_profit")], total_font, total_fill)
# 附注明细
notes = profit.get("notes") or {}
r += 1
r = write_row(ws1, r, ["附注一、收入构成", "", "", ""], section_font, section_fill)
for n in notes.get("revenue_breakdown", []):
r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]])
r = write_row(ws1, r, ["", "营业收入合计", "", notes.get("revenue_total")], total_font)
r = write_row(ws1, r, ["附注二、费用构成", "", "", ""], section_font, section_fill)
for n in notes.get("expense_breakdown", []):
r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]])
r = write_row(ws1, r, ["附注三、财务费用拆解", "", "", ""], section_font, section_fill)
for n in notes.get("finance_breakdown", []):
r = write_row(ws1, r, ["", n["name"], n["code"], n["amount"]])
r = write_row(ws1, r, ["附注四、板块勾稽(净利润=五板块之和)", "", "", ""], section_font, section_fill)
for n in notes.get("block_reconciliation", []):
r = write_row(ws1, r, ["", n["name"], "", n["amount"]])
r = write_row(ws1, r, ["", "净利润", "", notes.get("net_profit")], total_font, total_fill)
r = write_row(ws1, r, ["附注五、关键比率", "", "", ""], section_font, section_fill)
for n in notes.get("key_ratios", []):
r = write_row(ws1, r, ["", n["name"], "", n["value"]])
for col, w in zip("ABCD", [28, 40, 14, 18]):
ws1.column_dimensions[col].width = w
# ── Sheet 2: 资产负债表 ──
ws2 = wb.create_sheet("资产负债表-新30号准则")
bs = data["balance_sheet"]
style_header(ws2, 1, 5, f"资产负债表(新30号准则科目分类) {period} 单位: 元")
r = write_row(ws2, 2, ["项目", "新准则分类", "期末余额", "期初余额", "数据来源"], head_font, head_fill)
for sec in bs.get("sections", []):
r = write_row(ws2, r, [sec["name"], sec.get("category_label", ""), sec["subtotal_end"], sec["subtotal_begin"], "小计"],
section_font, section_fill)
for line in sec.get("lines", []):
src = "示例" if line.get("is_demo") else "凭证"
r = write_row(ws2, r, [line["name"], line.get("ns_category_label", ""),
line["end_value"], line["begin_value"], src])
t = bs.get("totals", {})
r = write_row(ws2, r, ["资产总计", "", t["assets"]["end"], t["assets"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["负债合计", "", t["liabilities"]["end"], t["liabilities"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["所有者权益合计", "", t["equity"]["end"], t["equity"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["负债和所有者权益总计", "", t["liab_equity"]["end"], t["liab_equity"]["begin"], ""], total_font, total_fill)
r = write_row(ws2, r, ["勾稽校验(资产=负债+权益)", "", "✓ 平衡" if t.get("balanced") else "✗ 不平", "", ""], total_font, total_fill)
for col, w in zip("ABCDE", [36, 16, 18, 18, 12]):
ws2.column_dimensions[col].width = w
# ── Sheet 3: 现金流量表 ──
ws3 = wb.create_sheet("现金流量表-新30号准则")
cf = data["cash_flow"]
style_header(ws3, 1, 4, f"现金流量表(新30号准则三活动) {period} 单位: 元")
r = write_row(ws3, 2, ["项目", "行次", "本期金额", "数据来源"], head_font, head_fill)
for sec in cf.get("sections", []):
r = write_row(ws3, r, [sec["name"], "", "", "小计"], section_font, section_fill)
for line in sec.get("lines", []):
src = "示例" if line.get("is_demo") else "凭证"
r = write_row(ws3, r, [line["name"], line["code"], line["value"], src])
r = write_row(ws3, r, [f"{sec['name']}净额", "", sec["net"], ""], total_font, total_fill)
fx = cf.get("fx_effect", {})
r = write_row(ws3, r, [fx.get("name", ""), "", fx.get("value"), "示例" if fx.get("is_demo") else "凭证"], section_font, section_fill)
s = cf.get("summary", {})
r = write_row(ws3, r, ["现金及现金等价物净增加额", "", s.get("net_increase"), ""], total_font, total_fill)
r = write_row(ws3, r, ["加:期初现金及现金等价物余额", "", s.get("begin_cash"), ""], total_font, total_fill)
r = write_row(ws3, r, ["期末现金及现金等价物余额", "", s.get("end_cash"), ""], total_font, total_fill)
for col, w in zip("ABCD", [46, 10, 18, 12]):
ws3.column_dimensions[col].width = w
buf = BytesIO()
wb.save(buf)
buf.seek(0)
from urllib.parse import quote
filename = f"对外法定报表_新30号准则_{period}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
def _get_dupont_kpi(db: Session, entity_id: int, kpi_code: str, period: str = "2026H1"):
"""杜邦分析 KPI 读取 — 按 entity + kpi_code 查 kpi_definitions → kpi_values
取 period 期间 data_status=='verified' 的最新一条(order_by id desc),读不到返回 None"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kpi_code,
).first()
if not kpi:
return None
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.data_status == "verified",
).order_by(KPIValue.id.desc()).first()
return v.actual_value if v else None
@router.get("/dupont")
def get_dupont_analysis(
entity: str = Query("bohai"),
db: Session = Depends(get_db),
):
"""杜邦分析 — ROE三级拆解 (CMA P2)"""
if entity == "bohai":
# 博海标准KPIF_REVENUE/F_NET_PROFIT)无verified值 → 优先DB读,读不到回退文档确认常量
net_profit = _get_dupont_kpi(db, 2, "F_NET_PROFIT")
if net_profit is None:
# 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1=141,324元)
net_profit = 14.13 # 万
revenue = _get_dupont_kpi(db, 2, "F_REVENUE")
if revenue is None:
# 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1=3,836,026元)
revenue = 383.6 # 万
# 来源: bohai_comprehensive_analysis_2026.md 试算平衡表 2026H1
total_assets = 533 # 万(DB无对应KPI,文档口径)
equity = 358.4 # 万(=3,583,851元)⚠️ 核心修正点:原硬编码114万错误,导致ROE高估约3倍
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)
debt_ratio = round((total_assets - equity) / total_assets * 100, 1)
# 上期对比(模拟上一期数据)
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": f"负债率{debt_ratio}%,结构健康",
"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"财务杠杆{financial_leverage}x健康,ROE {roe}%偏低主要因净利润率{net_profit_margin}%偏低。改善方向:提升毛利率、控制销售折扣/现金折扣支出(合计约110万侵蚀毛利)。",
},
}
if entity == "hanke":
# 酣客: F_REVENUE/F_NET_PROFIT 从DB读 verified 值(2026H1: 713.27 / -90.86
revenue = _get_dupont_kpi(db, 1, "F_REVENUE")
net_profit = _get_dupont_kpi(db, 1, "F_NET_PROFIT")
# 资产/权益 DB 无完整数据 → 对应 factor 返回 null,标注"数据待补充",不报错
total_assets = None
equity = None
net_profit_margin = round(net_profit / revenue * 100, 2) if revenue else None
if total_assets and equity:
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)
else:
# 资产/权益缺失:周转率与杠杆按中性值1估算,ROE≈净利率(净利润为负,符号确定)
asset_turnover = None
financial_leverage = None
roe = net_profit_margin
# 上期对比:prev_roe 无法获取 → null(前端已兼容 null,显示"无对比"
prev_roe = None
roe_change = None
roe_trend = None
return {
"entity": "hanke",
"entity_name": "陕西酣客文化传媒(白酒经销)",
"period": "2026年H1",
"roe": roe,
"roe_change": roe_change,
"roe_trend": roe_trend,
"prev_roe": prev_roe,
"factors": {
"net_profit_margin": {
"value": net_profit_margin,
"label": "净利润率",
"desc": "净利润/收入",
"status": "🔴" if (net_profit_margin or 0) < 0 else ("🟡" if (net_profit_margin or 0) < 5 else "✅"),
"assessment": "净利率为负,本期亏损经营",
"raw": {"net_profit": net_profit, "revenue": revenue},
},
"asset_turnover": {
"value": asset_turnover,
"label": "资产周转率",
"desc": "收入/总资产",
"status": "️",
"assessment": "数据待补充",
"raw": {"revenue": revenue, "total_assets": total_assets},
},
"financial_leverage": {
"value": financial_leverage,
"label": "财务杠杆",
"desc": "总资产/净资产",
"status": "️",
"assessment": "数据待补充",
"raw": {"total_assets": total_assets, "equity": equity},
},
},
"raw_data": {
"net_profit": net_profit,
"revenue": revenue,
"total_assets": total_assets,
"equity": equity,
},
"insight": {
"improvement": "止损优先:提升毛利率、控制费用;待资产/权益数据补充后计算完整ROE",
"detail": f"净利润{net_profit}万、净利率{net_profit_margin}%,白酒经销业务本期亏损(营收{revenue}万)。资产周转率与财务杠杆因资产/权益数据缺失暂无法计算(数据待补充),待补充后更新完整杜邦拆解。",
},
}
return {"error": "不支持的实体"}
# ============================================================
# 自动报告生成 — ChatBI优化P1
# 支持周报/月报/专项报告,定时/事件/手动触发
# ============================================================
WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
DIM_CN = {"finance": "财务维度", "customer": "客户维度", "process": "内部流程", "learning": "学习成长"}
ALERT_LEVEL_CN = {"red": "🔴 紧急", "yellow": "🟡 预警", "green": "🟢 正常"}
def _get_current_period(report_type: str) -> str:
"""根据报告类型自动计算当前期间"""
now = datetime.now()
if report_type == "weekly":
iso = now.isocalendar()
return f"{iso[0]}-W{iso[1]:02d}"
elif report_type == "monthly":
return now.strftime("%Y-%m")
elif report_type == "special":
return now.strftime("%Y-%m")
return now.strftime("%Y-%m")
def _calc_week_range(period: str) -> tuple:
"""周期间 → 起止日期"""
import datetime as dt
year, week = period.split("-W")
year, week = int(year), int(week)
# ISO week: week 1 is the week containing Jan 4
jan4 = dt.date(year, 1, 4)
start_of_week1 = jan4 - dt.timedelta(days=jan4.isoweekday() - 1)
monday = start_of_week1 + dt.timedelta(weeks=week - 1)
sunday = monday + dt.timedelta(days=6)
return monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d")
def _get_month_period_prefix(period: str) -> str:
"""YYYY-MM 的前期"""
y, m = period.split("-")
y, m = int(y), int(m)
m -= 1
if m <= 0:
m += 12
y -= 1
return f"{y}-{m:02d}"
def _fetch_kpi_data(db: Session) -> list:
"""获取所有活跃KPI的当前值、目标值、维度、预警"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
alerts = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.created_at.desc()).all()
result.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"unit": k.unit,
"target_value": k.target_value,
"current_value": latest.actual_value if latest else None,
"current_period": latest.period if latest else None,
"frequency": k.frequency,
"alerts": [
{"level": a.alert_level, "message": a.alert_message}
for a in alerts[:3]
],
})
return result
def _build_weekly_report(db: Session, period: str) -> dict:
"""生成周报"""
kpis = _fetch_kpi_data(db)
monday, sunday = _calc_week_range(period)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 最近7天新增的预警
from datetime import timedelta
seven_days_ago = datetime.now() - timedelta(days=7)
recent_alerts = db.query(KPIAlert).filter(
KPIAlert.created_at >= seven_days_ago,
KPIAlert.status == "pending",
).order_by(KPIAlert.created_at.desc()).all()
# 按维度分组统计
dim_stats = {}
for k in kpis:
d = k.get("dimension") or "other"
if d not in dim_stats:
dim_stats[d] = {"total": 0, "with_data": 0, "alert_count": 0}
dim_stats[d]["total"] += 1
if k["current_value"] is not None:
dim_stats[d]["with_data"] += 1
if k["alerts"]:
dim_stats[d]["alert_count"] += len(k["alerts"])
# KPI变动(取有环比数据的)
changes = []
for k in kpis:
if k["current_value"] is None:
continue
prev_period = _get_month_period_prefix(k["current_period"]) if k["current_period"] else None
if prev_period:
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k["kpi_id"],
KPIValue.period == prev_period,
).first()
if prev_val and prev_val.actual_value:
diff = round(k["current_value"] - prev_val.actual_value, 2)
rate = round(diff / prev_val.actual_value * 100, 2) if prev_val.actual_value != 0 else None
changes.append({
**k,
"prev_value": prev_val.actual_value,
"change": diff,
"change_rate": rate,
})
changes.sort(key=lambda x: abs(x.get("change_rate") or 0), reverse=True)
top_changes = changes[:8]
# ── 构建 Markdown ──
md_lines = [
f"📊 **经营分析周报**",
f"📅 {monday} ~ {sunday} | 生成时间:{now_str}",
"",
"---",
"",
"## 一、本周概览",
f"• 监控KPI{len(kpis)} 个 | 有数据:{sum(1 for k in kpis if k['current_value'] is not None)} 个",
f"• 待处理预警:{len(recent_alerts)} 条",
]
# 按维度展示
for dim_key, dim_label in [("finance", "💰 财务"), ("customer", "🤝 客户"), ("process", "⚙️ 流程"), ("learning", "📚 学习成长")]:
s = dim_stats.get(dim_key)
if s:
md_lines.append(f" - {dim_label}{s['total']}个KPI | {s['with_data']}个有数据 | {s['alert_count']}条预警")
md_lines.extend([
"",
"## 二、关键KPI变动 TOP8",
])
for c in top_changes:
direction = "📈" if (c.get("change_rate") or 0) > 0 else "📉"
rate_str = f"{c['change_rate']:+.1f}%" if c.get("change_rate") is not None else "-"
md_lines.append(
f" {direction} **{c['kpi_name']}**{c['current_value']}{c['unit']} "
f"(上期{c.get('prev_value', '-')},变动{rate_str}"
)
if recent_alerts:
md_lines.extend([
"",
"## 三、本周预警",
])
for a in recent_alerts[:10]:
kpi = next((k for k in kpis if k["kpi_id"] == a.kpi_id), None)
kpi_name = kpi["kpi_name"] if kpi else f"KPI#{a.kpi_id}"
md_lines.append(f" {ALERT_LEVEL_CN.get(a.alert_level, '⚠️')} {kpi_name}{a.alert_message}")
md_lines.extend([
"",
"## 四、改进行动",
])
actions = db.query(ActionPlan).filter(
ActionPlan.status.in_(["pending", "in_progress"]),
).order_by(ActionPlan.created_at.desc()).limit(5).all()
if actions:
for a in actions:
bar = "▓" * (a.progress // 10) + "░" * (10 - a.progress // 10)
md_lines.append(f" • {bar} {a.title}{a.progress}%- {a.assignee or '未分配'}")
else:
md_lines.append(" (暂无进行中的改善行动)")
md_lines.extend([
"",
"---",
f"💡 发送「分析报告」可重新生成",
])
markdown = "\n".join(md_lines)
# ── 构建 JSON ──
json_data = {
"report_type": "weekly",
"period": period,
"date_range": {"start": monday, "end": sunday},
"generated_at": now_str,
"overview": {
"total_kpis": len(kpis),
"kpis_with_data": sum(1 for k in kpis if k["current_value"] is not None),
"pending_alerts": len(recent_alerts),
},
"dimensions": {dk: {
"label": DIM_CN.get(dk, dk),
"kpi_count": ds["total"],
"with_data": ds["with_data"],
"alert_count": ds["alert_count"],
} for dk, ds in dim_stats.items()},
"top_changes": [
{
"kpi_code": c["kpi_code"],
"kpi_name": c["kpi_name"],
"current_value": c["current_value"],
"prev_value": c.get("prev_value"),
"change": c.get("change"),
"change_rate": c.get("change_rate"),
"unit": c["unit"],
}
for c in top_changes
],
"alerts": [
{
"kpi_id": a.kpi_id,
"alert_level": a.alert_level,
"alert_message": a.alert_message,
}
for a in recent_alerts[:10]
],
}
return {"markdown": markdown, "json": json_data, "title": f"经营分析周报 {monday}~{sunday}"}
def _build_monthly_report(db: Session, period: str) -> dict:
"""生成月报"""
kpis = _fetch_kpi_data(db)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
prev_period = _get_month_period_prefix(period)
# 预算执行数据
budget_items = []
for k in kpis:
dev = calc_period_deviation(db, k["kpi_id"], period)
if dev.get("actual_value") is not None or dev.get("budget_value") is not None:
budget_items.append({
"kpi_name": k["kpi_name"],
"kpi_code": k["kpi_code"],
"dimension": k["dimension"],
"actual": dev.get("actual_value"),
"budget": dev.get("budget_value"),
"deviation_rate": dev.get("deviation_rate"),
"unit": k["unit"],
})
# 同比/环比
comparisons = []
for k in kpis[:20]:
if k["current_value"] is None:
continue
mom = calc_period_diff(db, k["kpi_id"], period, "mom")
yoy = calc_period_diff(db, k["kpi_id"], period, "yoy")
comparisons.append({
"kpi_name": k["kpi_name"],
"kpi_code": k["kpi_code"],
"current": k["current_value"],
"unit": k["unit"],
"mom_rate": mom.get("diff_rate"),
"yoy_rate": yoy.get("diff_rate"),
})
# 预警汇总
pending_alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
).all()
red_count = sum(1 for a in pending_alerts if a.alert_level == "red")
yellow_count = sum(1 for a in pending_alerts if a.alert_level == "yellow")
# 各维度达成情况
dim_summary = {}
for k in kpis:
d = k.get("dimension") or "other"
if d not in dim_summary:
dim_summary[d] = {"total": 0, "achieved": 0, "warning": 0, "failed": 0}
dim_summary[d]["total"] += 1
if k["current_value"] is not None and k["target_value"]:
ratio = k["current_value"] / k["target_value"]
if ratio >= 0.9:
dim_summary[d]["achieved"] += 1
elif ratio >= 0.7:
dim_summary[d]["warning"] += 1
else:
dim_summary[d]["failed"] += 1
# 改善行动
actions = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(5).all()
# ── 生成 Markdown ──
md_lines = [
f"📊 **经营分析月报**",
f"📅 {period} | 生成时间:{now_str}",
"",
"---",
"",
"## 一、月度总览",
f"• 监控KPI{len(kpis)} 个",
f"• 预警状态:🔴 {red_count}条紧急 | 🟡 {yellow_count}条预警",
"",
"## 二、四维度达成情况",
]
for dk in ["finance", "customer", "process", "learning"]:
ds = dim_summary.get(dk)
if ds:
label = DIM_CN.get(dk, dk)
total = ds["total"]
achieved = ds["achieved"]
pct = round(achieved / total * 100, 1) if total > 0 else 0
bar_len = 10
filled = int(pct / 10)
bar = "▓" * filled + "░" * (bar_len - filled)
md_lines.append(f"• {label}{bar} {pct}%{achieved}/{total}达标)")
md_lines.extend([
"",
"## 三、预算执行 TOP异常",
])
budget_with_dev = [b for b in budget_items if b.get("deviation_rate") is not None]
budget_with_dev.sort(key=lambda x: abs(x["deviation_rate"]), reverse=True)
for b in budget_with_dev[:8]:
direction = "🔴" if (b["deviation_rate"] or 0) > 0 else "🟢"
md_lines.append(
f" {direction} **{b['kpi_name']}**:实际{b['actual']}{b['unit']} "
f"vs 预算{b['budget']}{b['unit']}(差异率{b['deviation_rate']:+.1f}%"
)
md_lines.extend([
"",
"## 四、同比/环比分析",
])
for c in comparisons[:8]:
mom_str = f"环比{c.get('mom_rate'):+.1f}%" if c.get("mom_rate") is not None else "环比N/A"
yoy_str = f"同比{c.get('yoy_rate'):+.1f}%" if c.get("yoy_rate") is not None else "同比N/A"
md_lines.append(f" • **{c['kpi_name']}**{c['current']}{c['unit']} | {mom_str} | {yoy_str}")
md_lines.extend([
"",
"## 五、改善行动进展",
])
if actions:
for a in actions:
bar = "▓" * (a.progress // 10) + "░" * (10 - a.progress // 10)
status_cn = {"pending": "待开始", "in_progress": "进行中", "completed": "已完成"}.get(a.status, a.status)
md_lines.append(f" • {bar} {a.title}{a.progress}%- {status_cn}")
else:
md_lines.append(" (暂无改善行动)")
md_lines.extend([
"",
"---",
f"💡 发送「生成{period}经营报告」可重新生成",
])
markdown = "\n".join(md_lines)
# ── 生成 JSON ──
json_data = {
"report_type": "monthly",
"period": period,
"generated_at": now_str,
"overview": {
"total_kpis": len(kpis),
"red_alerts": red_count,
"yellow_alerts": yellow_count,
},
"dimensions": {dk: {
"label": DIM_CN.get(dk, dk),
"total": ds["total"],
"achieved": ds["achieved"],
"achievement_rate": round(ds["achieved"] / ds["total"] * 100, 1) if ds["total"] > 0 else 0,
} for dk, ds in dim_summary.items()},
"budget_execution": [
{
"kpi_code": b["kpi_code"],
"kpi_name": b["kpi_name"],
"actual": b.get("actual"),
"budget": b.get("budget"),
"deviation_rate": b.get("deviation_rate"),
"unit": b["unit"],
}
for b in budget_with_dev[:15]
],
"comparisons": [
{
"kpi_code": c["kpi_code"],
"kpi_name": c["kpi_name"],
"current": c["current"],
"mom_rate": c.get("mom_rate"),
"yoy_rate": c.get("yoy_rate"),
}
for c in comparisons[:15]
],
}
return {"markdown": markdown, "json": json_data, "title": f"经营分析月报 {period}"}
def _build_special_report(db: Session, period: str, alert_ref: str = None) -> dict:
"""生成专项分析报告 — 聚焦KPI异常"""
kpis = _fetch_kpi_data(db)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 按偏差率排序(当前值/目标值)
scored = []
for k in kpis:
if k["current_value"] is not None and k["target_value"] and k["target_value"] > 0:
ratio = k["current_value"] / k["target_value"]
deviation = round((ratio - 1) * 100, 2)
scored.append({**k, "achievement_ratio": ratio, "deviation_pct": deviation})
scored.sort(key=lambda x: abs(x["deviation_pct"]), reverse=True)
top_issues = scored[:10]
worst_issues = [s for s in scored if s["deviation_pct"] < 0][:5]
best_issues = [s for s in scored if s["deviation_pct"] > 0][:3]
# 如果有预警引用,聚焦该预警关联的KPI
focus_kpi_name = None
if alert_ref:
alert = db.query(KPIAlert).filter(KPIAlert.id == int(alert_ref)).first() if alert_ref.isdigit() else None
if alert:
target_kpi = next((k for k in kpis if k["kpi_id"] == alert.kpi_id), None)
if target_kpi:
focus_kpi_name = target_kpi["kpi_name"]
# 维度分布
dim_issues = {}
for s in scored:
d = s.get("dimension") or "other"
if d not in dim_issues:
dim_issues[d] = {"on_track": 0, "at_risk": 0, "critical": 0}
if s["achievement_ratio"] >= 0.9:
dim_issues[d]["on_track"] += 1
elif s["achievement_ratio"] >= 0.7:
dim_issues[d]["at_risk"] += 1
else:
dim_issues[d]["critical"] += 1
# ── Markdown ──
md_lines = [
f"📊 **经营分析专项报告**",
f"📅 {period} | 生成时间:{now_str}",
]
if focus_kpi_name:
md_lines.append(f"🎯 触发事件:{focus_kpi_name} 异常预警")
md_lines.extend([
"",
"---",
"",
"## 一、风险总览",
])
for dk in ["finance", "customer", "process", "learning"]:
d = dim_issues.get(dk)
if d:
label = DIM_CN.get(dk, dk)
md_lines.append(
f"• {label}{d['on_track']}正常 / {d['at_risk']}预警 / {d['critical']}危险"
)
md_lines.extend([
"",
"## 二、风险KPI TOP 5(严重未达标)",
])
for w in worst_issues:
md_lines.append(
f" 🔴 **{w['kpi_name']}**:实际{w['current_value']}{w['unit']} "
f"vs 目标{w['target_value']}{w['unit']}(达成率{w['achievement_ratio']*100:.1f}%"
)
for a in w.get("alerts", []):
md_lines.append(f" ⚠️ {a['message']}")
md_lines.extend([
"",
"## 三、待处理预警详情",
])
pending_alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
).order_by(KPIAlert.created_at.desc()).limit(10).all()
if pending_alerts:
for a in pending_alerts:
target_kpi = next((k for k in kpis if k["kpi_id"] == a.kpi_id), None)
name = target_kpi["kpi_name"] if target_kpi else f"KPI#{a.kpi_id}"
md_lines.append(f" {ALERT_LEVEL_CN.get(a.alert_level, '⚠️')} {name}{a.alert_message}")
else:
md_lines.append(" ✅ 无待处理预警")
md_lines.extend([
"",
"## 四、改善建议",
])
for w in worst_issues:
if w["target_value"] and w["current_value"]:
gap = round(w["target_value"] - w["current_value"], 2)
md_lines.append(f" • **{w['kpi_name']}**:缺口{gap}{w['unit']},需提升至{w['target_value']}{w['unit']}才能达标")
md_lines.extend([
"",
"## 五、表现优秀KPI",
])
for b in best_issues:
md_lines.append(f" 🟢 **{b['kpi_name']}**{b['current_value']}{b['unit']},超目标{b['deviation_pct']:+.1f}%")
md_lines.extend([
"",
"---",
"💡 如有疑问,请回复「分析详情」获取更细颗粒度的数据",
])
markdown = "\n".join(md_lines)
# ── JSON ──
json_data = {
"report_type": "special",
"period": period,
"generated_at": now_str,
"focus_kpi": focus_kpi_name,
"alert_ref": alert_ref,
"risk_summary": {dk: {
"label": DIM_CN.get(dk, dk),
"on_track": dim_issues.get(dk, {}).get("on_track", 0),
"at_risk": dim_issues.get(dk, {}).get("at_risk", 0),
"critical": dim_issues.get(dk, {}).get("critical", 0),
} for dk in ["finance", "customer", "process", "learning"]},
"worst_kpis": [
{
"kpi_code": w["kpi_code"],
"kpi_name": w["kpi_name"],
"current_value": w["current_value"],
"target_value": w["target_value"],
"achievement_ratio": round(w["achievement_ratio"], 4),
"gap": round(w["target_value"] - w["current_value"], 2) if w["target_value"] and w["current_value"] else None,
"unit": w["unit"],
}
for w in worst_issues
],
"best_kpis": [
{
"kpi_code": b["kpi_code"],
"kpi_name": b["kpi_name"],
"current_value": b["current_value"],
"target_value": b["target_value"],
"achievement_ratio": round(b["achievement_ratio"], 4),
"unit": b["unit"],
}
for b in best_issues
],
"alerts": [
{
"kpi_id": a.kpi_id,
"alert_level": a.alert_level,
"alert_message": a.alert_message,
}
for a in pending_alerts[:10]
],
}
return {"markdown": markdown, "json": json_data, "title": f"经营分析专项报告 {period}"}
# ============================================================
# POST /api/cma/reports/generate — 自动报告生成主入口
# ============================================================
class GenerateReportRequest(BaseModel):
report_type: str = "monthly" # weekly / monthly / special
period: Optional[str] = None # 自动计算 if None
trigger_type: str = "manual" # manual / scheduled / event
alert_ref: Optional[str] = None # 事件触发时的预警ID
@router.post("/generate", response_model=None)
def generate_report(
req: GenerateReportRequest,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""生成经营分析报告(周报/月报/专项),返回markdown+JSON
触发方式:
- POST ?trigger_type=manual (用户主动触发)
- POST ?trigger_type=scheduled (定时任务触发)
- POST ?trigger_type=event&alert_ref=123 KPI异常事件触发)
"""
# 校验报告类型
if req.report_type not in ("weekly", "monthly", "special"):
raise HTTPException(400, f"不支持的报告类型: {req.report_type},可选: weekly/monthly/special")
# 确定期间
period = req.period or _get_current_period(req.report_type)
# 生成报告
builders = {
"weekly": lambda db, period: _build_weekly_report(db, period),
"monthly": lambda db, period: _build_monthly_report(db, period),
"special": lambda db, period: _build_special_report(db, period, alert_ref=req.alert_ref),
}
builder = builders[req.report_type]
try:
report_data = builder(db, period)
except Exception as e:
logger.error(f"报告生成异常: {e}", exc_info=True)
raise HTTPException(500, f"报告生成失败: {str(e)}")
# 保存到数据库
record = ReportHistory(
report_type=req.report_type,
period=period,
title=report_data["title"],
markdown_content=report_data["markdown"],
json_content=report_data["json"],
status="generated",
trigger_type=req.trigger_type,
alert_ref=req.alert_ref,
)
db.add(record)
db.flush()
# 记录操作日志
log = OperationLog(
action="generate_report",
target_type="report",
target_id=record.id,
detail=json.dumps({
"report_type": req.report_type,
"period": period,
"trigger_type": req.trigger_type,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
db.refresh(record)
return {
"id": record.id,
"report_type": req.report_type,
"period": period,
"title": report_data["title"],
"generated_at": record.created_at.strftime("%Y-%m-%d %H:%M:%S") if record.created_at else datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"markdown": report_data["markdown"],
"json": report_data["json"],
}
@router.get("/history")
def list_report_history(
report_type: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""查看报告生成历史"""
query = db.query(ReportHistory).order_by(ReportHistory.created_at.desc())
if report_type:
query = query.filter(ReportHistory.report_type == report_type)
records = query.limit(limit).all()
return {
"total": len(records),
"data": [
{
"id": r.id,
"report_type": r.report_type,
"period": r.period,
"title": r.title,
"status": r.status,
"trigger_type": r.trigger_type,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
for r in records
],
}
@router.get("/history/{report_id}")
def get_report_detail(
report_id: int,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""获取单条报告详情(含完整markdown内容)"""
r = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
if not r:
raise HTTPException(404, "报告不存在")
return {
"id": r.id,
"report_type": r.report_type,
"period": r.period,
"title": r.title,
"status": r.status,
"trigger_type": r.trigger_type,
"alert_ref": r.alert_ref,
"markdown": r.markdown_content,
"json": r.json_content,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}