ci/woodpecker/push/main Pipeline failed
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).
184 lines
9.4 KiB
Python
184 lines
9.4 KiB
Python
#!/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())
|