feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
ci/woodpecker/push/main Pipeline failed

Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI").

- kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql:
  F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI
  (real IMA caliber = operating profit / invested capital), F_EVA activated with unit
  and thresholds aligned to 万元, new F_RI (residual income).
- parameters are NOT hardcoded in formula text: they live in
  kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate /
  include_factory_subsidy / include_channel_subsidy / include_sales_discount /
  annualize / capital_basis); single reader = app/utils/profitability_params.py.
- deriver scripts/kpi_profitability_deriver.py: YTD operating profit from
  voucher_details (carry_forward=0 net), invested capital from subject_balances
  level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no
  data_status downgrade, fail-closed when invested capital is 0).
- read-only endpoint GET /reports/value-management reads the derived rows only
  (single source of truth, no recomputation) and exposes the parameter block.
- DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI).
- caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row.
- tests: backend/tests/test_profitability_indicators.py (21 cases).
This commit is contained in:
Hermes CI Fix
2026-10-03 02:10:47 +08:00
parent 1da0a74568
commit 8631ce718f
7 changed files with 1113 additions and 4 deletions
+83
View File
@@ -21,6 +21,7 @@ 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, calc_deviation, get_budget_for_kpi
from app.utils.alert_entity_scope import month_period_clause
from app.utils.profitability_params import load_profitability_params
from app.utils.voucher_guard import apply_voucher_guard
from app.services import report_reclass
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
@@ -2333,6 +2334,88 @@ def get_dupont_analysis(
return {"error": "不支持的实体"}
# ============================================================
# 价值管理区 — 真 ROI / ROA / EVA / RI(只读)
# 口径: 财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
# ============================================================
@router.get("/value-management")
def get_value_management(
period: Optional[str] = Query(None, description="期间 YYYY-MM;缺省取该账套最新实测月"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""价值管理区 — 真 ROI / 总资产报酬率(ROA) / EVA / 剩余收益(RI)。
单一事实源:**只读 `kpi_values` 里 `source_type='derived'` 的派生行**(由
`scripts/kpi_profitability_deriver.py` 落库),本端点**不复算**——避免「同一指标两份结果」。
参数由 `app/utils/profitability_params.py` 从 `kpi_definitions.data_source_config.params` 读取。
口径要点:YTD 累计(非年化);投入资本 = 有息负债(2001+2501) + 所有者权益(3xxx 一级科目);
厂补 530103 / 渠补 571103 计入经营(开关可回旧口径)。
"""
codes = ("F_ROI", "F_ROA", "F_EVA", "F_RI", "F_ROE")
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(codes),
).all()}
ids = [d.id for d in defs.values()]
if period is None and ids:
latest = db.query(KPIValue.period).filter(
KPIValue.kpi_id.in_(ids),
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
period = latest[0] if latest else None
rows = []
if ids and period:
rows = db.query(KPIValue).filter(
KPIValue.kpi_id.in_(ids),
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.id.desc()).all()
metric = {}
for row in rows: # id desc ⇒ 每个 kpi 取最新一条(同指标多来源时显式暴露,不去重掩盖)
kpi = next((d for d in defs.values() if d.id == row.kpi_id), None)
if kpi is None or kpi.kpi_code in metric:
continue
metric[kpi.kpi_code] = {
"value": row.actual_value,
"unit": kpi.unit,
"kpi_name": kpi.kpi_name,
"source_type": row.source_type,
"data_status": row.data_status,
"source_batch": row.source_batch,
"remark": row.remark,
"formula_desc": kpi.formula_desc,
}
params = load_profitability_params(db, entity_id)
return {
"entity_id": entity_id,
"period": period,
"roa": metric.get("F_ROA"),
"roi": metric.get("F_ROI"),
"eva": metric.get("F_EVA"),
"ri": metric.get("F_RI"),
"roe": metric.get("F_ROE"),
"params": {
"tax_rate": params["tax_rate"],
"wacc": params["wacc"],
"min_return_rate": params["min_return_rate"],
"include_factory_subsidy": params["include_factory_subsidy"],
"include_channel_subsidy": params["include_channel_subsidy"],
"include_sales_discount": params["include_sales_discount"],
"annualize": params["annualize"],
"capital_basis": params["capital_basis"],
},
"readonly": True,
"data_source": "derived",
}
# ============================================================
# 自动报告生成 — ChatBI优化P1
# 支持周报/月报/专项报告,定时/事件/手动触发
+9 -2
View File
@@ -86,10 +86,17 @@ RATIO_CALIBER_TABLE = {
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
},
"F_ROI": {
"F_ROA": {
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.57 已是百分数",
"evidence": "2026-08 实测 -0.57 已是百分数(kpi_code 2026-10-03 由 F_ROI 正名为 F_ROA,"
"kpi_id/历史值不变;真 ROI 另立 F_ROI)",
},
"F_ROI": {
"name": "投入资本回报率(ROI)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 派生实测 -6.74(YTD 营业利润÷投入资本,已是百分数;口径见 "
"wecom-finance 2026-10-02 裁定)",
},
"F_DEBT_RATIO": {
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
+94
View File
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""盈利能力三指标(ROI / EVA / RI)参数单一事实源 —— 口径裁定 2026-10-02
依据(唯一有效出处):财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
任务件 `finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`
口径(万元):
· 真 ROI(IMA/CMA 口径) = 营业利润 ÷ 投入资本
投入资本 = 有息负债(2001 短期借款 + 2501 长期借款)+ 所有者权益(3xxx 一级科目净额)
· EVA = NOPAT − 投入资本 × WACC ,NOPAT = 经营口径营业利润 × (1 − 所得税率)
· RI = 营业利润 − 投入资本 × 最低报酬率
· 经营口径营业利润 = (5001 − 5401 − 5403 − 5601 − 5602 − 5603) + 厂补(530103) − 渠补(571103)
厂补/渠补**计入经营**(参数开关可回旧口径);571112 商品折扣(销售) 默认**不计入**
参数化纪律(任务单 §二-1「勿硬编码进公式文本」):
所有参数存放于 `kpi_definitions.data_source_config` 的 `params` 子对象,**单一事实源**:
· `F_ROI.data_source_config.params` —— 利润口径开关(三条指标共用)
· `F_EVA.data_source_config.params` —— tax_rate / wacc
· `F_RI.data_source_config.params` —— min_return_rate
本模块是**唯一读取入口**(派生器与只读端点共用),读不到即回落 DEFAULTS。
改参数 = 改 DB 的 JSON 后重跑派生器(无需改代码),见 `scripts/kpi_profitability_deriver.py`。
"""
from __future__ import annotations
import json
from typing import Any, Dict
from app.models import KPIDefinition
#: 三条指标的 kpi_code(与种子 SQL `scripts/sql/profitability_kpi_defs_20261003.sql` 一致)
ROI_KPI = "F_ROI"
EVA_KPI = "F_EVA"
RI_KPI = "F_RI"
PROFITABILITY_KPIS = (ROI_KPI, EVA_KPI, RI_KPI)
#: 出厂默认参数(DB 参数缺失/未解析时的兜底;与裁定 §一 一致)
DEFAULTS: Dict[str, Any] = {
# —— 利润口径开关(三条共用,落 F_ROI.params)——
"include_factory_subsidy": True, # 厂补 530103 计入经营
"include_channel_subsidy": True, # 渠补 571103 计入经营
"include_sales_discount": False, # 571112 商品折扣(销售) 默认不计入(未决开关,任总点头后置 true)
# —— 资本成本参数 ——
"tax_rate": 0.25, # 所得税率(落 F_EVA.params)
"wacc": 0.08, # 加权平均资本成本(落 F_EVA.params)
"min_return_rate": 0.08, # RI 最低报酬率(落 F_RI.params,默认同 WACC)
# —— 口径开关 ——
"annualize": False, # 是否年化(默认 False = YTD 累计口径,公式/备注已声明)
"capital_basis": "ending", # 投入资本口径:ending=期末 / average=平均((期初+期末)/2)
}
def parse_params(raw: Any) -> Dict[str, Any]:
"""把 `data_source_config`(dict / JSON 字符串 / None)解析成 params 子对象。
兼容形态:`{"params": {...}}`(本单约定) / 裸 `{...}`(历史行无 params 包装) / None。
"""
if raw is None:
return {}
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", "replace")
if isinstance(raw, str):
raw = raw.strip()
if not raw:
return {}
try:
raw = json.loads(raw)
except (ValueError, TypeError):
return {}
if not isinstance(raw, dict):
return {}
params = raw.get("params", raw)
if not isinstance(params, dict):
return {}
return {k: v for k, v in params.items() if v is not None}
def load_profitability_params(db, entity_id: int) -> Dict[str, Any]:
"""读取并合并三条指标行的参数(F_ROI ← F_EVA ← F_RI 逐层覆盖 DEFAULTS)。
读不到定义/参数 ⇒ 对应层不覆盖(DEFAULTS 兜底)。返回的 dict 恒含 DEFAULTS 的全部键。
"""
out = dict(DEFAULTS)
rows = db.query(KPIDefinition.kpi_code, KPIDefinition.data_source_config).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
).all()
by_code = {}
for code, cfg in rows:
by_code[code] = parse_params(cfg)
for code in PROFITABILITY_KPIS:
for k, v in (by_code.get(code) or {}).items():
if k in out:
out[k] = v
return out