fix(reports): profit-summary 真实科目取数重建(去 KPI 比率倒推/硬编码 0.50 + 勾稽自检 + entity 隔离)
ci/woodpecker/push/main Pipeline was canceled

This commit is contained in:
hermes
2026-10-14 23:14:41 +08:00
parent 6d8ef0d443
commit 2244cd79b7
4 changed files with 829 additions and 118 deletions
+31 -104
View File
@@ -3,7 +3,7 @@ CMA管理报表中心 — 管理会计OS
非传统财务报表,聚焦管理决策分析
报表:
1. 管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润
1. 管理利润表 — 收入→变动成本→边际贡献→固定成本→营业利润(真实科目取数)
2. 预算执行报告 — 各KPI预算vs实际vs差异率
3. KPI趋势报告 — 选定KPI的历史趋势
4. 四维度绩效评分卡 — BSC健康度雷达图
@@ -28,6 +28,8 @@ from app.utils.dupont_basis import (
resolve_window, stock_balance, sum_flows,
)
from app.utils.voucher_guard import apply_voucher_guard
from app.utils.profit_lines import (BOTTOM_LABEL, UNIT, build_items, compute_lines,
prev_period_of, reconcile)
from app.services import report_reclass
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
import logging
@@ -49,116 +51,41 @@ def get_profit_summary(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→息税前利润"""
"""管理利润表 — 收入→变动成本→边际贡献→固定成本→营业利润(**真实科目取数**,按 entity 隔离)
2026-10-14 重建(原实现 3 处硬伤已消除):
① 原 L74 `contribution_margin = gross_profit`(边际贡献=毛利)→ 改真账:收入 − 变动成本;
② 原 L76 变动成本 = 营收 × 硬编码比例(五成)→ 科目口径现算,**本端点零比例常量**;
③ 原末行标签「= 息税前利润」却装净利率算出的 net_profit → 标签=算法=「营业利润」
(= 收入 − 变动成本 − 固定成本;未扣所得税/营业外 ⇒ 不称净利润,已扣 6603 财务费用 ⇒ 不称 EBIT)。
口径唯一源:`app/utils/bep_caliber.py::ENTITY_CALIBERS`(变动/固定科目构成一律从该处派生,
本报表层不定义任何科目集);取数实现见 `app/utils/profit_lines.py`(voucher_details 真实发生额)。
勾稽自检:响应 `reconciliation`(收入 − 变动成本 − 固定成本 = 末行;不平显式标出,禁静默不平)。
"""
if period is None:
period = datetime.now().strftime("%Y-%m")
prev_period = prev_period_of(period)
# 从KPI数据中获取各利润要素
def get_val(code: str):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).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, KPIDefinition.entity_id == entity_id).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,
},
]
cur = compute_lines(db, entity_id, period, period)
prev = compute_lines(db, entity_id, prev_period, prev_period)
values = cur.get("values") or {}
return {
"period": period,
"prev_period": prev_period,
"items": items,
"entity_id": entity_id,
"unit": UNIT,
"bottom_label": BOTTOM_LABEL,
"source": "voucher_details 真实凭证明细发生额(按 entity 隔离;剔结转腿 + 作废/撤单行)",
"available": cur["available"],
"missing": cur["missing"],
"prev_available": prev["available"],
"prev_missing": prev["missing"],
"caliber": cur.get("caliber"),
"items": build_items(cur, prev),
"reconciliation": reconcile(values.get("revenue"), values.get("variable_cost"),
values.get("fixed_cost"), values.get("result")),
}