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