Files
cma-management/backend/scripts/kpi_profitability_deriver.py
Hermes CI Fix 8631ce718f
ci/woodpecker/push/main Pipeline failed
feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
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).
2026-10-03 02:10:47 +08:00

357 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)