#!/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)