Files
cma-management/backend/scripts/kpi_dupont_deriver.py
T
Hermes CI Fix 38a6de9325
ci/woodpecker/push/main Pipeline failed
feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
Task: finance-20261003-dupont-period-basis-design-fullstack (P1; proposer wecom-finance)
Basis ruling: finance 2026-10-03 (Boss 09:49 CST DM approval of the design task)

- /reports/dupont accepts period/start/end/annualize (default YTD), returns
  period_start/period_end/basis/annualized metadata; identity guard
  |ROE - npm x turnover x multiplier| <= 0.05 pp, else identity_broken (no values).
- new app/utils/dupont_basis.py = single source of truth (window resolve, flow
  cumulative via voucher_details, stock average via subject_balances level=1,
  fail-closed basis_fallback, pure calc + identity check).
- scripts/kpi_dupont_deriver.py (idempotent derived writer, dry-run/check/write),
  scripts/apply_dupont_kpi_defs.py + sql/dupont_kpi_defs_20261003.sql (seed:
  F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER as derived; F_ASSET_TURNOVER
  manual -> derived). Seed already applied under approval 296cc1fd.
- scripts/verify_dupont_period_basis.py: read-only acceptance battery A1-A9
  (independent SQL recompute). BEFORE (base tree) 1/10 PASS -> AFTER 10/13 PASS;
  remaining 3 need the deriver value write (approval-gated).
- frontend DupontAnalysis.vue: period selector (YTD default) + annualize switch
  + caliber metadata bar + identity_broken alert.

No DDL. No ERP direct connection (old register_dupont_kpis.py caliber dropped).
2026-10-04 13:35:25 +08:00

184 lines
9.4 KiB
Python
Raw 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 -*-
"""杜邦·存量与乘数派生器 —— F_ASSET_TOTAL / F_EQUITY_TOTAL / F_EQUITY_MULTIPLIER / F_ASSET_TURNOVER
任务:`finance-20261003-dupont-period-basis-design-fullstack.md`(P1,提出者 wecom-finance)
放行出处:任总 2026-10-03 09:49 CST DM「立」
口径(任务件 §二,单一事实源 `app/utils/dupont_basis.py`):
· 总资产(期末) = Σ(cb_dr−cb_cr) over subject_balances level=1 AND code LIKE '1%'
· 净资产(期末) = Σ(cb_cr−cb_dr) over subject_balances level=1 AND code LIKE 权益前缀
· 权益乘数 = **平均总资产 ÷ 平均净资产**((期初+期末)/2;期初缺失 ⇒ 显式回落期末)
· 总资产周转率 = 区间**累计**营业收入 ÷ **平均总资产**(单月口径;跨期比较用 /reports/dupont 年化)
取数:CMA 库内 `subject_balances`(存量)+ `voucher_details`(流量)。**不用**旧脚本
`register_dupont_kpis.py` 的 ERP 直连近似口径(缺应收/存货)。
幂等:同 (kpi_id, period, source_type='derived') 存在则原位更新;**不降级 data_status**
(kpi_values 无业务唯一键 ⇒ 必须 SELECT→UPDATE/INSERT,禁 ON DUPLICATE KEY)。
边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。
用法:
venv/bin/python3 scripts/kpi_dupont_deriver.py --check # 只算不写(只读闸门形态)
venv/bin/python3 scripts/kpi_dupont_deriver.py --dry-run --entity 1 # 同 --check
venv/bin/python3 scripts/kpi_dupont_deriver.py --write --entity all # 落库(需放行)
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
from app.database import get_session_local # noqa: E402
from app.models import KPIDefinition, KPIValue # noqa: E402
from app.utils.dupont_basis import ( # noqa: E402
available_months, latest_stock_month, load_dupont_params, stock_balance, sum_flows,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.dupont_deriver")
KPI_CODES = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER", "F_ASSET_TURNOVER")
def derive_one(db, entity_id: int, period: str, caliber: dict) -> dict:
"""算一个账套一个期间的四个值(不写库)。"""
eq_prefixes = list(caliber["equity_prefixes"])
ta = stock_balance(db, entity_id, period, [caliber["asset_prefix"]], "net")
eq = stock_balance(db, entity_id, period, eq_prefixes, "net_rev")
if ta.get("end") is None or eq.get("end") is None:
return {"entity_id": entity_id, "period": period, "unavailable": True,
"reason": f"{period} 无 subject_balances 一级科目行(资产/权益)"}
fallback = ta.get("begin") is None or eq.get("begin") is None
ta_avg = ta["end"] if fallback else (ta["begin"] + ta["end"]) / 2.0
eq_avg = eq["end"] if fallback else (eq["begin"] + eq["end"]) / 2.0
revenue = sum_flows(db, entity_id, period, period,
{"codes": caliber["revenue_codes"], "field": "credit", "carry_forward": 0})
w = lambda v: None if v is None else v / 10000.0
ta_w, eq_w, ta_avg_w, eq_avg_w, rev_w = w(ta["end"]), w(eq["end"]), w(ta_avg), w(eq_avg), w(revenue)
values = {
"F_ASSET_TOTAL": round(ta_w, 2),
"F_EQUITY_TOTAL": round(eq_w, 2),
"F_EQUITY_MULTIPLIER": round(ta_avg_w / eq_avg_w, 4) if eq_avg_w else None,
"F_ASSET_TURNOVER": round(rev_w / ta_avg_w, 4) if ta_avg_w else None,
}
basis_txt = "期末回落(期初缺失)" if fallback else "平均(期初+期末)/2"
remarks = {
"F_ASSET_TOTAL": f"期末总资产 {ta_w:.2f}万(level=1,code LIKE '{caliber['asset_prefix']}%')",
"F_EQUITY_TOTAL": (f"期末净资产 {eq_w:.2f}万(level=1,权益前缀 {'/'.join(eq_prefixes)})"),
"F_EQUITY_MULTIPLIER": (f"权益乘数 = 平均总资产 {ta_avg_w:.2f}万 ÷ 平均净资产 {eq_avg_w:.2f}万 "
f"= {values['F_EQUITY_MULTIPLIER']}({basis_txt})"),
"F_ASSET_TURNOVER": (f"总资产周转率 = 区间营业收入 {rev_w:.2f}万 ÷ 平均总资产 {ta_avg_w:.2f}万 "
f"= {values['F_ASSET_TURNOVER']}({basis_txt};单月口径,跨期比较用 dupont 端点年化)"),
}
return {"entity_id": entity_id, "period": period, "unavailable": False,
"basis_fallback": fallback, "values": values, "remarks": remarks,
"raw": {"total_assets_end": ta_w, "equity_end": eq_w, "total_assets_avg": ta_avg_w,
"equity_avg": eq_avg_w, "revenue": rev_w}}
def write_one(db, res: dict, write: bool = True) -> dict:
"""幂等落库:同 (kpi_id, period, source_type='derived') 原位 UPDATE,否则 INSERT。"""
if res.get("unavailable"):
return {"written": "unavailable"}
entity_id, period = res["entity_id"], res["period"]
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(KPI_CODES)),
).all()}
batch = f"dupont-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
for code in KPI_CODES:
kpi = defs.get(code)
val = res["values"].get(code)
if kpi is None:
written[code] = "no-definition"
logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用 dupont_kpi_defs_20261003.sql",
code, entity_id)
continue
if val is None:
written[code] = "unavailable(分母为0)"
continue
if not write:
written[code] = f"dry-run({val})"
continue
row = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if row:
old = float(row.actual_value) if row.actual_value is not None else None
row.actual_value = val
row.source_batch = batch
row.remark = res["remarks"][code] + (f"|值更新(旧{old} → 新{val})" if old != val else "")
written[code] = "upsert" # 不降级 data_status(ASK-20260924-10)
else:
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=val,
source_type="derived", source_batch=batch,
data_status="pending", remark=res["remarks"][code]))
written[code] = "insert"
db.flush()
if write:
db.commit()
return {"written": written, "batch": batch}
def main() -> int:
ap = argparse.ArgumentParser(description="杜邦·存量与乘数派生器")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)")
ap.add_argument("--entity", default="all", help="entity_id 或 all(默认 all=1,2)")
ap.add_argument("--write", action="store_true", help="实际落库(缺省=只算不写)")
ap.add_argument("--dry-run", action="store_true", help="只算不写(同缺省)")
ap.add_argument("--check", action="store_true", help="只读检查(同 --dry-run,供闸门只读标志)")
args = ap.parse_args()
write = bool(args.write)
entities = [1, 2] if args.entity == "all" else [int(args.entity)]
db = get_session_local()()
rc = 0
try:
for eid in entities:
caliber = load_dupont_params(db, eid)
# 默认期间 = **存量最新月**(subject_balances 有 level=1 行)= 可算存量口径的最后一个月;
# 不能用流量最新月(实测 entity2 流量到 2026-09 而存量只到 2026-08 ⇒ 会算空)
period = args.period or latest_stock_month(db, eid)
if not period:
logger.error("entity=%s 无 subject_balances 一级科目行,无可用期间", eid)
rc = 2
continue
res = derive_one(db, eid, period, caliber)
if res.get("unavailable"):
print(f"entity={eid} period={period} 不可得:{res['reason']}")
rc = 2
continue
out = write_one(db, res, write=write)
r = res["raw"]
print(f"entity={eid} period={period} mode={'write' if write else 'dry-run'} "
f"batch={out.get('batch')} 口径来源={caliber.get('_params_source')}")
print(f" 期末总资产 {r['total_assets_end']:.2f}万 / 期末净资产 {r['equity_end']:.2f}万 / "
f"平均总资产 {r['total_assets_avg']:.2f}万 / 平均净资产 {r['equity_avg']:.2f}万 / "
f"营业收入 {r['revenue']:.2f}万"
+ (" ⚠️ basis_fallback=期初缺失,已回落期末" if res["basis_fallback"] else ""))
print(f" F_ASSET_TOTAL={res['values']['F_ASSET_TOTAL']}万 "
f"F_EQUITY_TOTAL={res['values']['F_EQUITY_TOTAL']}万 "
f"F_EQUITY_MULTIPLIER={res['values']['F_EQUITY_MULTIPLIER']}倍 "
f"F_ASSET_TURNOVER={res['values']['F_ASSET_TURNOVER']}次")
print(f" 落库: {out['written']}")
finally:
db.close()
return rc
if __name__ == "__main__":
sys.exit(main())