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
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""盈利能力三指标派生器 —— 真 ROI / EVA / RI(cma-profitability 2026-10-03)
任务:`finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`(P1,提出者 wecom-finance)
放行出处:任总 2026-10-02 11:59 CST DM「立;但①要补真ROI」
口径(财务裁定,万元):
营业利润(旧口径) = 5001 − 5401 − 5403 − (5601 + 5602 + 5603)
经营口径营业利润 = 旧口径 + 厂补(530103) − 渠补(571103) [参数开关可回旧口径]
(可选 +571112 商品折扣(销售),默认不计入)
投入资本 = 有息负债(2001 + 2501) + 所有者权益(3xxx 一级科目净额)
真 ROI = 经营口径营业利润 ÷ 投入资本 × 100
NOPAT = 经营口径营业利润 × (1 − 所得税率)
EVA = NOPAT − 投入资本 × WACC
RI = 经营口径营业利润 − 投入资本 × 最低报酬率
取数:
· 损益科目 → `voucher_details`(净额 = 借−贷,`carry_forward=0` 行域,同 kpi_bep_deriver)
· 投入资本 → `subject_balances`(期末余额 cb_cr−cb_dr;3xxx 仅取 `level=1` 一级科目,防跨层双计)
· 期间口径 = **YTD 累计(年初至目标月)**;是否年化由参数 `annualize` 控制(默认 False,
即 ROI/EVA/RI 均为 YTD 非年化口径,已写进 kpi_definitions.formula_desc 与 remark)
参数:`app/utils/profitability_params.py`(唯一读取入口;落 `kpi_definitions.data_source_config.params`)
幂等:同 kpi_id + entity + period + source_type='derived' 存在则原位更新;**不降级 data_status**
(ASK-20260924-10 口径:保留既有状态,含人工放行的 verified)
边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。
用法:
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 # 落库
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 --dry-run # 只算不写
venv/bin/python3 scripts/kpi_profitability_deriver.py --entity 1 --period 2026-08
"""
import argparse
import logging
import os
import sys
from datetime import datetime
from typing import Any, Dict, Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import func, text
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, VoucherDetail
from app.utils.profitability_params import (
EVA_KPI, PROFITABILITY_KPIS, RI_KPI, ROI_KPI, load_profitability_params,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.profitability_deriver")
#: 损益科目口径(与任务单 §一 逐字一致)
SALES_CODE = "5001" # 主营业务收入(贷方)
COST_CODE = "5401" # 主营业务成本
TAX_SURCHARGE_CODE = "5403" # 营业税金及附加
EXPENSE_PREFIXES = ("5601", "5602", "5603") # 销售/管理/财务费用
FACTORY_SUBSIDY_CODE = "530103" # 厂补(贷方)
CHANNEL_SUBSIDY_CODE = "571103" # 渠补(借方)
SALES_DISCOUNT_CODE = "571112" # 商品折扣(销售),默认不计入
#: 有息负债(一级科目)
INTEREST_BEARING_CODES = ("2001", "2501")
#: 所有者权益前缀(一级科目 3xxx,恰 4 位;用 LIKE 保证 MySQL/SQLite 双引擎可跑)
EQUITY_PREFIX = "3"
EQUITY_LEVEL1_LIKE = "3___"
# ────────────────────────── 取数 ──────────────────────────
def ytd_window(period: str) -> tuple:
"""目标期间 → YTD 起止(年初 .. 当期)"""
year = period[:4]
return f"{year}-01", period
def sum_voucher(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None) -> float:
"""聚合 voucher_details 金额**元**。field: 'net'(借−贷) / 'debit_amount' / 'credit_amount'。
净额口径只在 `carry_forward=0` 行域成立(含结转腿时损益类净额恒 0)——见
/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
"""
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
if field == "net" else getattr(VoucherDetail, field))
q = db.query(func.sum(expr)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if subject_filter:
kind, code = subject_filter
if kind == "eq":
q = q.filter(VoucherDetail.subject_code == code)
elif kind == "like":
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
return float(q.scalar() or 0.0)
def operating_profit(db, entity_id: int, period: str, params: Dict[str, Any]) -> Dict[str, float]:
"""YTD 营业利润(万元):旧口径 / 经营口径 + 各项明细,全部留痕供复核。"""
p_from, p_to = ytd_window(period)
sales = sum_voucher(db, entity_id, p_from, p_to, "credit_amount", ("eq", SALES_CODE))
cost = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", COST_CODE))
tax_surcharge = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", TAX_SURCHARGE_CODE))
expenses = sum(sum_voucher(db, entity_id, p_from, p_to, "net", ("like", p))
for p in EXPENSE_PREFIXES)
op_legacy = (sales - cost - tax_surcharge - expenses) / 10000.0
factory_subsidy = -sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", FACTORY_SUBSIDY_CODE)) / 10000.0
channel_subsidy = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", CHANNEL_SUBSIDY_CODE)) / 10000.0
sales_discount = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", SALES_DISCOUNT_CODE)) / 10000.0
op = op_legacy
if params["include_factory_subsidy"]:
op += factory_subsidy
if params["include_channel_subsidy"]:
op -= channel_subsidy
if params["include_sales_discount"]:
op -= sales_discount
return {
"sales_w": round(sales / 10000.0, 4),
"cost_w": round(cost / 10000.0, 4),
"tax_surcharge_w": round(tax_surcharge / 10000.0, 4),
"expenses_w": round(expenses / 10000.0, 4),
"op_profit_legacy_w": round(op_legacy, 4),
"factory_subsidy_w": round(factory_subsidy, 4),
"channel_subsidy_w": round(channel_subsidy, 4),
"sales_discount_w": round(sales_discount, 4),
"op_profit_w": round(op, 4),
}
def _equity_sql():
return text("""
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
FROM subject_balances
WHERE entity_id = :eid AND period = :period AND `level` = 1
AND subject_code LIKE :eq_like
""")
def _debt_sql():
return text("""
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
FROM subject_balances
WHERE entity_id = :eid AND period = :period AND `level` = 1
AND subject_code IN ('2001', '2501')
""")
def invest_capital(db, entity_id: int, period: str, basis: str = "ending") -> Dict[str, Any]:
"""投入资本(万元)= 有息负债 + 所有者权益(`subject_balances` 期末/平均)。
3xxx **只取 `level=1`(一级科目)**:`row_kind` 有 subtotal_level1/detail 两层,
含明细层会把 `3104 利润分配` 与其子科目 `310415` 双计(实测 +598.55 万虚增)。
"""
def _query(p: str) -> Dict[str, float]:
debt = float(db.execute(_debt_sql(), {"eid": entity_id, "period": p}).scalar() or 0.0)
equity = float(db.execute(_equity_sql(), {"eid": entity_id, "period": p,
"eq_like": EQUITY_LEVEL1_LIKE}).scalar() or 0.0)
return {"debt": debt / 10000.0, "equity": equity / 10000.0}
cur = _query(period)
out = {
"interest_bearing_debt_w": round(cur["debt"], 4),
"equity_w": round(cur["equity"], 4),
"basis": basis,
"ending_w": round(cur["debt"] + cur["equity"], 4),
}
total = out["ending_w"]
if basis == "average":
y, m = int(period[:4]), int(period[5:7])
if m == 1:
prev_period = f"{y - 1}-12"
else:
prev_period = f"{y}-{m - 1:02d}"
prev = _query(prev_period)
prev_ok = (prev["debt"] + prev["equity"]) != 0.0
out["prev_period"] = prev_period
out["prev_total_w"] = round(prev["debt"] + prev["equity"], 4)
if prev_ok:
total = (out["ending_w"] + out["prev_total_w"]) / 2.0
else:
out["average_fallback"] = "上一期 subject_balances 缺数 → 回落期末口径"
out["basis"] = "ending(fallback)"
out["invest_capital_w"] = round(total, 4)
return out
# ────────────────────────── 计算 ──────────────────────────
def compute_indicators(op_profit_w: float, invest_capital_w: float,
months: int, params: Dict[str, Any]) -> Dict[str, Optional[float]]:
"""纯计算(无 DB,便于单测):返回三条指标值(None = 不可得,fail-closed)。"""
if not invest_capital_w:
return {ROI_KPI: None, EVA_KPI: None, RI_KPI: None, "nopat_w": None, "scale": None}
scale = (12.0 / months) if (params.get("annualize") and months) else 1.0
op = op_profit_w * scale
nopat = op * (1.0 - float(params["tax_rate"]))
return {
ROI_KPI: round(op / invest_capital_w * 100.0, 2),
"nopat_w": round(nopat, 4),
EVA_KPI: round(nopat - invest_capital_w * float(params["wacc"]), 2),
RI_KPI: round(op - invest_capital_w * float(params["min_return_rate"]), 2),
"scale": scale,
}
# ────────────────────────── 落库 ──────────────────────────
def run_derive(db, entity_id: int, period: str, write_kpi: bool = True) -> Dict[str, Any]:
params = load_profitability_params(db, entity_id)
months = int(period[5:7])
profit = operating_profit(db, entity_id, period, params)
capital = invest_capital(db, entity_id, period, params.get("capital_basis", "ending"))
values = compute_indicators(profit["op_profit_w"], capital["invest_capital_w"], months, params)
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
).all()}
switch_txt = (f"厂补{'计入' if params['include_factory_subsidy'] else '不计入'}/"
f"渠补{'计入' if params['include_channel_subsidy'] else '不计入'}/"
f"商品折扣{'计入' if params['include_sales_discount'] else '不计入'}")
basis_txt = ("YTD累计(非年化)" if not params.get("annualize")
else f"YTD年化(×12/{months})")
common = (f"营业利润({switch_txt}; {basis_txt}) {profit['op_profit_w']:.2f}万 / "
f"投入资本({capital['basis']}) {capital['invest_capital_w']:.2f}万")
remarks = {
ROI_KPI: (f"{common} × 100 = {values[ROI_KPI]}%"
if values[ROI_KPI] is not None else f"{common}|投入资本为 0,ROI 不可得"),
EVA_KPI: (f"NOPAT {values['nopat_w']:.2f}万(税率{params['tax_rate']:.0%}) − "
f"投入资本 {capital['invest_capital_w']:.2f}万 × WACC {params['wacc']:.2%} "
f"= {values[EVA_KPI]}万" if values[EVA_KPI] is not None else f"{common}|投入资本为 0"),
RI_KPI: (f"营业利润 {profit['op_profit_w']:.2f}万 − 投入资本 {capital['invest_capital_w']:.2f}万 × "
f"最低报酬率 {params['min_return_rate']:.2%} = {values[RI_KPI]}万"
if values[RI_KPI] is not None else f"{common}|投入资本为 0"),
}
batch = f"profit-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written, changed = {}, []
for code in PROFITABILITY_KPIS:
kpi = defs.get(code)
if kpi is None:
written[code] = "no-definition"
logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用种子 SQL", code, entity_id)
continue
if values[code] is None:
written[code] = "unavailable(invest_capital=0)"
continue
if not write_kpi:
written[code] = "dry-run"
continue
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing:
old = float(existing.actual_value) if existing.actual_value is not None else None
new = float(values[code])
existing.actual_value = new
existing.source_batch = batch
if old is None or abs(old - new) > 1e-9:
existing.remark = f"{remarks[code]}|值已更新(batch={batch}, 旧{old} → 新{new}) 待财务复核"
changed.append({"code": code, "old": old, "new": new,
"data_status": existing.data_status})
else:
existing.remark = remarks[code]
# 不降级 data_status(ASK-20260924-10)
written[code] = "upsert"
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=values[code],
source_type="derived",
source_batch=batch,
data_status="pending",
remark=remarks[code],
))
written[code] = "insert"
db.flush()
if write_kpi:
db.commit()
return {
"entity_id": entity_id,
"period": period,
"months": months,
"mode": "write" if write_kpi else "dry-run",
"batch": batch,
"params": params,
"profit": profit,
"capital": capital,
"nopat_w": values.get("nopat_w"),
"values": {c: values[c] for c in PROFITABILITY_KPIS},
"remarks": remarks,
"written": written,
"changed": changed,
}
def _pick_period(db, entity_id: int) -> Optional[str]:
"""默认目标期间 = subject_balances 中该账套最新的期间(投入资本的唯一可取期间)。"""
row = db.execute(text(
"SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid"
), {"eid": entity_id}).scalar()
return str(row) if row else None
def main():
ap = argparse.ArgumentParser(description="盈利能力三指标派生器(真 ROI / EVA / RI)")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)")
ap.add_argument("--entity", type=int, default=1, help="entity_id(默认 1=陕西酣客)")
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
args = ap.parse_args()
db = get_session_local()()
try:
period = args.period or _pick_period(db, args.entity)
if not period:
logger.error("entity=%s 无 subject_balances 记录,无可用期间", args.entity)
return 2
r = run_derive(db, args.entity, period, write_kpi=not args.dry_run)
print(f"盈利能力派生完成 entity={r['entity_id']} period={r['period']} "
f"(YTD {r['months']}个月) mode={r['mode']} batch={r['batch']}")
p, c = r["profit"], r["capital"]
print(f" 销售收入 {p['sales_w']:.2f}万 − 成本 {p['cost_w']:.2f}万 − 税金及附加 {p['tax_surcharge_w']:.2f}万 "
f"− 三费 {p['expenses_w']:.2f}万 = 旧口径营业利润 {p['op_profit_legacy_w']:.2f}万")
print(f" 厂补 {p['factory_subsidy_w']:.2f}万 / 渠补 {p['channel_subsidy_w']:.2f}万 / "
f"商品折扣 {p['sales_discount_w']:.2f}万 → 经营口径营业利润 {p['op_profit_w']:.2f}万")
print(f" 投入资本({c['basis']}) = 有息负债 {c['interest_bearing_debt_w']:.2f}万 + "
f"所有者权益 {c['equity_w']:.2f}万 = {c['invest_capital_w']:.2f}万")
print(f" F_ROI = {r['values'][ROI_KPI]} %")
print(f" F_EVA = {r['values'][EVA_KPI]} 万元 (NOPAT {r['nopat_w']:.2f}万)")
print(f" F_RI = {r['values'][RI_KPI]} 万元")
print(f" 落库: {r['written']}")
if r["changed"]:
print(f" 值变: {r['changed']}")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main() or 0)
@@ -0,0 +1,80 @@
-- CMA 盈利能力三指标(真 ROI / EVA / RI)定义收敛 — 2026-10-03
-- 任务: finance-20261002-profitability-indicators-roi-eva-ri-fullstack(部署目标 cma-backend · cma-frontend)
-- 口径: 财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
-- 动作:
-- ① F_ROI 正名 → F_ROA(**沿用原 kpi_id=20 与全部 8 期历史值,不删行**;名称「总资产报酬率(ROA)」不变)
-- ② 新增 F_ROI(真口径:经营口径营业利润 ÷ 投入资本)
-- ③ F_EVA 启用 status='active' + 单位/阈值对齐「万元」(原 unit='元' 而派生值以万元落库 ⇒ 会恒黄/量级失真)
-- ④ 新增 F_RI(剩余收益)
-- 参数: 全部落 data_source_config.params(可配置、非硬编码进公式文本);
-- 读取入口唯一 = app/utils/profitability_params.py
-- 幂等: 条件 UPDATE + NOT EXISTS 守卫,可安全重跑
-- ⚠️ 顺序不可换:①必须先于②((entity_id,kpi_code) 唯一键 uk_entity_kpi 会撞)
-- ① F_ROI 正名 → F_ROA(仅当 F_ROA 尚不存在;历史值随 kpi_id 保留)
UPDATE kpi_definitions
SET kpi_code = 'F_ROA', updated_at = NOW()
WHERE entity_id = 1 AND kpi_code = 'F_ROI'
AND NOT EXISTS (
SELECT 1 FROM (SELECT id FROM kpi_definitions
WHERE entity_id = 1 AND kpi_code = 'F_ROA') AS t
);
-- ② 新增 F_ROI(真口径)
INSERT INTO kpi_definitions
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
data_source_type, data_source, data_owner, frequency, unit, target_value,
target_calc_type, threshold_green, threshold_yellow, threshold_red,
kpi_level, status, bot_source, epic, created_by, data_source_config)
SELECT 1, 'F_ROI', '投入资本回报率(ROI)', 'finance', 'profitability',
'经营口径营业利润 / 投入资本 * 100',
'真ROI=营业利润÷投入资本; 投入资本=有息负债(2001+2501)+所有者权益(3xxx一级科目); YTD累计(非年化); '
'厂补530103/渠补571103计入经营(开关 include_factory_subsidy/include_channel_subsidy); '
'571112商品折扣默认不计入(include_sales_discount); 参数见 data_source_config.params',
'derived', '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
'财务部', 'monthly', '%', 0.00,
'ratio', NULL, NULL, NULL,
'strategic', 'active', 'finance-bot', 'Epic2', 1,
'{"params": {"include_factory_subsidy": true, "include_channel_subsidy": true, "include_sales_discount": false, "annualize": false, "capital_basis": "ending"}, "param_note": "利润口径开关,三条指标(F_ROI/F_EVA/F_RI)共用;读取入口 app/utils/profitability_params.py"}'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_ROI'
);
-- ③ F_EVA 启用 + 万元口径对齐(阈值语义逐字平移:原 绿>=0元 / 黄>=-100000元 / 红<-100000元
-- ⇔ 万元 绿>=0 / 黄>=-10 / 红<-10;不改判定语义,只消除 元↔万元 跨量级失真)
UPDATE kpi_definitions
SET status = 'active',
unit = '万元',
target_value = 0.00,
target_calc_type = 'accumulate',
formula = 'NOPAT - 投入资本 * WACC',
formula_desc = 'EVA=NOPAT−投入资本×WACC; NOPAT=经营口径营业利润×(1−所得税率); 单位万元; '
'YTD累计(非年化); 资本成本参数见 data_source_config.params(tax_rate/wacc)',
data_source_type = 'derived',
data_source = '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
data_owner = '财务部',
threshold_green = '>=0', threshold_yellow = '>=-10', threshold_red = '<-10',
data_source_config = '{"params": {"tax_rate": 0.25, "wacc": 0.08}}',
updated_at = NOW()
WHERE entity_id = 1 AND kpi_code = 'F_EVA';
-- ④ 新增 F_RI(剩余收益)
INSERT INTO kpi_definitions
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
data_source_type, data_source, data_owner, frequency, unit, target_value,
target_calc_type, threshold_green, threshold_yellow, threshold_red,
kpi_level, status, bot_source, epic, created_by, data_source_config)
SELECT 1, 'F_RI', '剩余收益(RI)', 'finance', 'profitability',
'经营口径营业利润 - 投入资本 * 最低报酬率',
'RI=营业利润−投入资本×最低报酬率(同WACC族,EVA为先扣税版本); 单位万元; YTD累计(非年化); '
'最低报酬率参数见 data_source_config.params.min_return_rate; 部门级 RI 待部门级资产负债后下沉',
'derived', '盈利能力派生器 kpi_profitability_deriver.py(subject_balances + voucher_details)',
'财务部', 'monthly', '万元', 0.00,
'accumulate', NULL, NULL, NULL,
'strategic', 'active', 'finance-bot', 'Epic2', 1,
'{"params": {"min_return_rate": 0.08}}'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_RI'
);