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).
357 lines
16 KiB
Python
357 lines
16 KiB
Python
#!/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)
|