2611 lines
101 KiB
Python
2611 lines
101 KiB
Python
"""
|
||
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": [("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": [("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},
|
||
"1221": {"end": 90, "begin": 80},
|
||
"1405": {"end": 930, "begin": 900},
|
||
"1511": {"end": 580, "begin": 550},
|
||
"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": 460, "begin": 540},
|
||
}
|
||
|
||
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)}"},
|
||
)
|
||
|
||
|
||
@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": "不支持的实体"}
|
||
|
||
|
||
# ============================================================
|
||
# 自动报告生成 — 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,
|
||
}
|