feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
ci/woodpecker/push/main Pipeline failed
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).
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""杜邦 KPI 定义种子应用器 —— 执行 scripts/sql/dupont_kpi_defs_20261003.sql
|
||||
|
||||
任务:finance-20261003-dupont-period-basis-design-fullstack.md
|
||||
动作:① 两账套注册 F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER(derived)
|
||||
② F_ASSET_TURNOVER data_source_type: manual → derived
|
||||
幂等:SQL 自带条件 INSERT(NOT EXISTS)+ 条件 UPDATE,可安全重跑。
|
||||
|
||||
用法(**写库**,须先过 approval-gate 放行):
|
||||
venv/bin/python3 scripts/apply_dupont_kpi_defs.py --check # 只读预检(不执行任何语句)
|
||||
venv/bin/python3 scripts/apply_dupont_kpi_defs.py --apply # 执行(需放行)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
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_engine # noqa: E402
|
||||
|
||||
SQL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"sql", "dupont_kpi_defs_20261003.sql")
|
||||
TARGETS = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER")
|
||||
TARGET_ENTITIES = (1, 2)
|
||||
|
||||
|
||||
def statements(sql_text: str):
|
||||
"""按 ASCII ';' 切分(本文件的字符串字面量内不含 ';',已人工核对)"""
|
||||
out = []
|
||||
for chunk in sql_text.split(";"):
|
||||
stmt = chunk.strip()
|
||||
if stmt and not all(ln.strip().startswith("--") or not ln.strip() for ln in stmt.splitlines()):
|
||||
out.append(stmt)
|
||||
return out
|
||||
|
||||
|
||||
def snapshot(conn) -> dict:
|
||||
rows = conn.execute(text("""SELECT entity_id, kpi_code, data_source_type, status
|
||||
FROM kpi_definitions
|
||||
WHERE entity_id IN (1,2) AND kpi_code IN
|
||||
('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER',
|
||||
'F_ASSET_TURNOVER')
|
||||
ORDER BY entity_id, kpi_code""")).fetchall()
|
||||
return {f"{r[0]}/{r[1]}": (r[2], r[3]) for r in rows}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--check", action="store_true", help="只读预检(不执行写语句)")
|
||||
ap.add_argument("--apply", action="store_true", help="执行种子 SQL(写库)")
|
||||
args = ap.parse_args()
|
||||
if not (args.check or args.apply):
|
||||
print("须指定 --check 或 --apply", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
eng = get_engine()
|
||||
with open(SQL_FILE, encoding="utf-8") as f:
|
||||
stmts = statements(f.read())
|
||||
print(f"SQL 文件: {SQL_FILE}")
|
||||
print(f"语句数: {len(stmts)}")
|
||||
for i, s in enumerate(stmts, 1):
|
||||
head = " ".join(s.split())[:110]
|
||||
print(f" [{i}] {head}...")
|
||||
|
||||
with eng.connect() as conn:
|
||||
before = snapshot(conn)
|
||||
print(f"\n写前像({len(before)} 行): {before}")
|
||||
if args.check:
|
||||
print("\n[--check] 只读预检:未执行任何语句(写路径未触发)")
|
||||
return 0
|
||||
for i, s in enumerate(stmts, 1):
|
||||
r = conn.execute(text(s))
|
||||
print(f" [{i}] rowcount={r.rowcount}")
|
||||
conn.commit()
|
||||
after = snapshot(conn)
|
||||
print(f"\n写后像({len(after)} 行): {after}")
|
||||
added = {k: v for k, v in after.items() if k not in before}
|
||||
changed = {k: (before[k], v) for k, v in after.items() if k in before and before[k] != v}
|
||||
print(f"新增: {added}")
|
||||
print(f"变更: {changed}")
|
||||
missing = [f"{e}/{c}" for e in TARGET_ENTITIES for c in TARGETS if f"{e}/{c}" not in after]
|
||||
print(f"缺件: {missing or '无'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,105 @@
|
||||
-- CMA 杜邦分析「区间取值」KPI 定义收敛 — 2026-10-03
|
||||
-- 任务: finance-20261003-dupont-period-basis-design-fullstack(部署目标 cma-backend · cma-frontend)
|
||||
-- 口径: 财务Bot 2026-10-03 裁定(任总 09:49 CST DM「立」)—— 详见任务件 §二
|
||||
-- 动作:
|
||||
-- ① 新增 F_ASSET_TOTAL(总资产)/ F_EQUITY_TOTAL(净资产)/ F_EQUITY_MULTIPLIER(权益乘数)
|
||||
-- 为 derived KPI(**两账套**:entity 1 酣客 / entity 2 博海)
|
||||
-- ② F_ASSET_TURNOVER 由 manual → derived(口径=区间营业收入 ÷ 平均总资产,由派生器出数)
|
||||
-- 单一事实源: app/utils/dupont_basis.py(口径/取数/计算)+ scripts/kpi_dupont_deriver.py(落值)
|
||||
-- 幂等: 条件 INSERT(NOT EXISTS)+ 条件 UPDATE,可安全重跑
|
||||
-- ⚠️ 取数源: CMA 库内 subject_balances(存量 level=1)+ voucher_details(流量),
|
||||
-- **不用**旧脚本 register_dupont_kpis.py 的 ERP 直连近似口径(缺应收/存货)
|
||||
|
||||
-- ── ① entity 1(酣客,旧准则科目:权益 3xxx)────────────────────────────
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, kpi_level, status, bot_source, created_by, data_source_config)
|
||||
SELECT 1, 'F_ASSET_TOTAL', '总资产', 'finance', 'asset_efficiency',
|
||||
'期末总资产 = SUM(subject_balances.cb_dr-cb_cr) WHERE level=1 AND subject_code LIKE ''1%''',
|
||||
'总资产期末余额(万元),一级科目(level=1)防跨层双计;来源 CMA 库内 subject_balances。'
|
||||
'杜邦恒等式需用**平均**口径,见 F_EQUITY_MULTIPLIER 与 /reports/dupont 的 basis=average',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1,
|
||||
'{"params": {"asset_prefix": "1", "equity_prefixes": ["3"], "revenue_codes": ["5001"], "net_profit_mode": "carry_forward"}, "param_note": "账套科目体系口径,读取入口 app/utils/dupont_basis.py load_dupont_params"}'
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_ASSET_TOTAL');
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, kpi_level, status, bot_source, created_by)
|
||||
SELECT 1, 'F_EQUITY_TOTAL', '净资产', 'finance', 'asset_efficiency',
|
||||
'期末净资产 = SUM(subject_balances.cb_cr-cb_dr) WHERE level=1 AND subject_code LIKE ''3%''',
|
||||
'净资产期末余额(万元)= 所有者权益合计;一级科目(level=1)。来源 CMA 库内 subject_balances',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_EQUITY_TOTAL');
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||
kpi_level, status, bot_source, created_by)
|
||||
SELECT 1, 'F_EQUITY_MULTIPLIER', '权益乘数', 'finance', 'asset_efficiency',
|
||||
'平均总资产 / 平均净资产((期初+期末)/2,level=1)',
|
||||
'权益乘数=平均总资产÷平均净资产;**平均口径** = 区间(期初+期末)/2,与 /reports/dupont '
|
||||
'同区间;期初缺失显式回落期末并标注 basis_fallback(禁静默)',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '倍', 2.00, 'ratio', '<=2', '<=3', '>3',
|
||||
'strategic', 'active', 'finance-bot', 1
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_EQUITY_MULTIPLIER');
|
||||
|
||||
-- ── ① entity 2(博海,新 30 号准则科目:权益 4xxx,损益 6xxx)────────────
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, kpi_level, status, bot_source, created_by, data_source_config)
|
||||
SELECT 2, 'F_ASSET_TOTAL', '总资产', 'finance', 'asset_efficiency',
|
||||
'期末总资产 = SUM(subject_balances.cb_dr-cb_cr) WHERE level=1 AND subject_code LIKE ''1%''',
|
||||
'总资产期末余额(万元),一级科目(level=1)防跨层双计;来源 CMA 库内 subject_balances',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1,
|
||||
'{"params": {"asset_prefix": "1", "equity_prefixes": ["4"], "revenue_codes": ["6001", "6051", "6052"], "net_profit_mode": "pnl_net"}, "param_note": "博海用新30号准则科目体系(权益 4xxx / 损益 6xxx),读取入口 app/utils/dupont_basis.py"}'
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_ASSET_TOTAL');
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, kpi_level, status, bot_source, created_by)
|
||||
SELECT 2, 'F_EQUITY_TOTAL', '净资产', 'finance', 'asset_efficiency',
|
||||
'期末净资产 = SUM(subject_balances.cb_cr-cb_dr) WHERE level=1 AND subject_code LIKE ''4%''',
|
||||
'净资产期末余额(万元)= 所有者权益合计(新30号准则 4xxx);一级科目(level=1)',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '万元', 0.00, 'accumulate', 'strategic', 'active', 'finance-bot', 1
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_EQUITY_TOTAL');
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||
kpi_level, status, bot_source, created_by)
|
||||
SELECT 2, 'F_EQUITY_MULTIPLIER', '权益乘数', 'finance', 'asset_efficiency',
|
||||
'平均总资产 / 平均净资产((期初+期末)/2,level=1)',
|
||||
'权益乘数=平均总资产÷平均净资产;**平均口径** = 区间(期初+期末)/2,与 /reports/dupont 同区间',
|
||||
'derived', '杜邦派生器 kpi_dupont_deriver.py(subject_balances level=1)',
|
||||
'财务部', 'monthly', '倍', 2.00, 'ratio', '<=2', '<=3', '>3',
|
||||
'strategic', 'active', 'finance-bot', 1
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_EQUITY_MULTIPLIER');
|
||||
|
||||
-- ── ② F_ASSET_TURNOVER: manual → derived(两账套)─────────────────────
|
||||
-- 值型阈值不改;只改「谁出数 + 口径文本」,避免单位/量级语义被顺手改动
|
||||
UPDATE kpi_definitions
|
||||
SET data_source_type = 'derived',
|
||||
data_source = '杜邦派生器 kpi_dupont_deriver.py(subject_balances + voucher_details)',
|
||||
formula = '营业收入 / 平均总资产',
|
||||
formula_desc = '总资产周转率 = 区间**累计**营业收入 ÷ 区间**平均**总资产((期初+期末)/2,level=1);'
|
||||
'单月口径该值天然很小(≠年化),跨期比较请在 /reports/dupont 用 annualize=true(×12/月数)',
|
||||
updated_at = NOW()
|
||||
WHERE entity_id IN (1, 2) AND kpi_code = 'F_ASSET_TURNOVER'
|
||||
AND (data_source_type IS NULL OR data_source_type <> 'derived');
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""杜邦分析「区间取值」验收电池 —— 任务 finance-20261003-dupont-period-basis-design-fullstack
|
||||
|
||||
判别力设计:**改动前必 FAIL、改动后必 PASS**。本脚本直达 `/reports/dupont` 端点函数体
|
||||
(绕过 HTTP/鉴权),逐项断言任务件 §三 验收标准;数值侧用**独立 SQL 复算**
|
||||
(不复用 app/utils/dupont_basis.py 的取数代码)作比对,避免「自己验自己」。
|
||||
|
||||
每节用 `with Section(...)` 隔离:改动前(端点不接受 period/annualize)得到的是一张
|
||||
全 FAIL 的表而非崩溃堆栈 —— 判别力证据才可比。
|
||||
|
||||
用法(只读;无任何写路径):
|
||||
venv/bin/python3 scripts/verify_dupont_period_basis.py --check # 全量验收
|
||||
venv/bin/python3 scripts/verify_dupont_period_basis.py --check --json # 附 JSON
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
if not any(a in ("--check", "--dry-run") for a in sys.argv[1:]):
|
||||
print("拒绝执行:本脚本只读(仅 SELECT/端点只读调用,无 commit),须带显式只读标志 --check",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
from starlette.requests import Request # noqa: E402
|
||||
|
||||
from app.database import get_session_local # noqa: E402
|
||||
from app.api.reports import get_dupont_analysis # noqa: E402
|
||||
|
||||
RESULTS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> bool:
|
||||
RESULTS.append({"item": name, "ok": bool(ok), "detail": detail})
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" | {detail}" if detail else ""))
|
||||
return bool(ok)
|
||||
|
||||
|
||||
class Section:
|
||||
"""把一节断言包成「异常 = FAIL」而不是整脚本崩溃(判别力取证的必备形态)。"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if exc is not None:
|
||||
check(f"{self.name} —— 本节断言未能执行(改动前端点形态差异必现)", False,
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def stub_request(path: str = "/api/cma/reports/dupont") -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": path, "query_string": b"",
|
||||
"headers": [(b"host", b"verify.local")]})
|
||||
|
||||
|
||||
def call(db, **kw):
|
||||
"""直达端点函数体(绕过 HTTP 与 router 级 require_role)。
|
||||
|
||||
⚠️ FastAPI 直调陷阱:省略 kwarg 拿到的是 `Query(None)` 哨兵而非 `None`(会
|
||||
`AttributeError: 'Query' object has no attribute ...`)⇒ 探针侧必须显式补齐全部形参。
|
||||
"""
|
||||
for k, v in (("entity", None), ("period", None), ("start", None), ("end", None),
|
||||
("annualize", False), ("guard_probe", None)):
|
||||
kw.setdefault(k, v)
|
||||
return get_dupont_analysis(request=stub_request(), db=db, **kw)
|
||||
|
||||
|
||||
def scalar(db, sql, **args):
|
||||
v = db.execute(text(sql), args).scalar()
|
||||
return None if v is None else float(v)
|
||||
|
||||
|
||||
# ────────────────── 独立复算(纯 SQL,不复用 dupont_basis) ──────────────────
|
||||
|
||||
def independent_recompute(db, entity_id: int, start: str, end: str,
|
||||
equity_prefix: str, revenue_codes=("5001",),
|
||||
net_profit_mode: str = "carry_forward") -> dict:
|
||||
"""独立口径复算(万元):流量累计 + 存量平均(期初/期末),与端点输出对拍。"""
|
||||
codes = ",".join(f"'{c}'" for c in revenue_codes)
|
||||
rev = scalar(db, f"""SELECT COALESCE(SUM(credit_amount),0) FROM voucher_details
|
||||
WHERE entity_id=:e AND period>=:s AND period<=:e2
|
||||
AND carry_forward=0 AND subject_code IN ({codes})""",
|
||||
e=entity_id, s=start, e2=end)
|
||||
if net_profit_mode == "carry_forward":
|
||||
np_flow = scalar(db, """SELECT COALESCE(SUM(credit_amount-debit_amount),0) FROM voucher_details
|
||||
WHERE entity_id=:e AND period>=:s AND period<=:e2
|
||||
AND carry_forward=1 AND subject_code LIKE :p""",
|
||||
e=entity_id, s=start, e2=end, p=f"{equity_prefix}%")
|
||||
else:
|
||||
np_flow = scalar(db, """SELECT COALESCE(SUM(credit_amount-debit_amount),0) FROM voucher_details
|
||||
WHERE entity_id=:e AND period>=:s AND period<=:e2
|
||||
AND carry_forward=0 AND subject_code LIKE '6%'""",
|
||||
e=entity_id, s=start, e2=end)
|
||||
|
||||
def bal(period, field, side, prefix):
|
||||
ob = "ob_dr-ob_cr" if side == "debit" else "ob_cr-ob_dr"
|
||||
cb = "cb_dr-cb_cr" if side == "debit" else "cb_cr-cb_dr"
|
||||
expr = {"ob": ob, "cb": cb}[field]
|
||||
return scalar(db, f"""SELECT COALESCE(SUM({expr}),0) FROM subject_balances
|
||||
WHERE entity_id=:e AND period=:p AND `level`=1
|
||||
AND subject_code LIKE :pfx""",
|
||||
e=entity_id, p=period, pfx=f"{prefix}%") or 0.0
|
||||
|
||||
ta_ob, ta_cb = bal(start, "ob", "debit", "1"), bal(end, "cb", "debit", "1")
|
||||
eq_ob, eq_cb = bal(start, "ob", "credit", equity_prefix), bal(end, "cb", "credit", equity_prefix)
|
||||
ta_avg, eq_avg = (ta_ob + ta_cb) / 2.0, (eq_ob + eq_cb) / 2.0
|
||||
np_w, rev_w, ta_w, eq_w = np_flow / 1e4, rev / 1e4, ta_avg / 1e4, eq_avg / 1e4
|
||||
return {
|
||||
"net_profit": round(np_w, 6), "revenue": round(rev_w, 6),
|
||||
"total_assets": round(ta_w, 6), "equity": round(eq_w, 6),
|
||||
"total_assets_begin": round(ta_ob / 1e4, 6), "equity_begin": round(eq_ob / 1e4, 6),
|
||||
"total_assets_end": round(ta_cb / 1e4, 6), "equity_end": round(eq_cb / 1e4, 6),
|
||||
"net_profit_margin": None if not rev_w else round(np_w / rev_w * 100.0, 6),
|
||||
"asset_turnover": None if not ta_w else round(rev_w / ta_w, 6),
|
||||
"equity_multiplier": None if not eq_w else round(ta_w / eq_w, 6),
|
||||
"roe": None if not eq_w else round(np_w / eq_w * 100.0, 6),
|
||||
}
|
||||
|
||||
|
||||
def sec_a1(db):
|
||||
with Section("A1"):
|
||||
d = call(db, entity="hanke")
|
||||
meta_ok = all(k in d for k in ("period_start", "period_end", "basis", "annualized"))
|
||||
check("A1 无参默认=YTD 且含 period_start/period_end/basis/annualized",
|
||||
meta_ok and d.get("period_key") == "ytd"
|
||||
and str(d.get("period_start")).endswith("-01"),
|
||||
f"period_key={d.get('period_key')} {d.get('period_start')}~{d.get('period_end')} "
|
||||
f"basis={d.get('basis')} annualized={d.get('annualized')}")
|
||||
check("A1b 默认口径恒等式 PASS(identity.status=pass)",
|
||||
d.get("identity", {}).get("status") == "pass",
|
||||
f"identity={d.get('identity')}")
|
||||
|
||||
|
||||
def sec_a2(db):
|
||||
m = None
|
||||
with Section("A2"):
|
||||
m = call(db, entity="hanke", period="2026-08", annualize=False)
|
||||
exp = independent_recompute(db, 1, "2026-08", "2026-08", "3")
|
||||
pairs = []
|
||||
for k in ("net_profit", "revenue", "total_assets", "equity"):
|
||||
got, want = m["raw_data"][k], exp[k]
|
||||
pairs.append((k, got, want, abs((got or 0) - (want or 0)) < 1e-6))
|
||||
for k in ("net_profit_margin", "asset_turnover", "equity_multiplier"):
|
||||
got, want = m["factors"][k]["value_exact"], exp[k]
|
||||
pairs.append((k, got, want, abs((got or 0) - (want or 0)) < 1e-5))
|
||||
check("A2 单月 2026-08 非年化:原值+三因子与独立 SQL 复算逐项一致",
|
||||
all(p[3] for p in pairs),
|
||||
"; ".join(f"{p[0]}={p[1]}/{p[2]}{'✗' if not p[3] else ''}" for p in pairs))
|
||||
check("A2b 单月区间 basis=average(期初取同月 ob_*,非回落)",
|
||||
m.get("basis") == "average" and m.get("basis_fallback") is False,
|
||||
f"basis={m.get('basis')} fallback={m.get('basis_fallback')}")
|
||||
return m
|
||||
|
||||
|
||||
def sec_a3(db, m):
|
||||
if not m:
|
||||
check("A3 年化对比(前序 A2 未取到 payload,节略)", False, "")
|
||||
return
|
||||
with Section("A3"):
|
||||
y = call(db, entity="hanke", period="2026-08", annualize=True)
|
||||
k = y["annualize_scale"]
|
||||
tol = max(1e-6, 1e-6 * k) # value_exact 已 round(6),乘 k 后同比例允差
|
||||
okat = abs(y["factors"]["asset_turnover"]["value_exact"]
|
||||
- m["factors"]["asset_turnover"]["value_exact"] * k) <= tol
|
||||
okroe = abs((y["roe_exact"] or 0) - (m["roe_exact"] or 0) * k) <= tol
|
||||
oknpm = abs(y["factors"]["net_profit_margin"]["value_exact"]
|
||||
- m["factors"]["net_profit_margin"]["value_exact"]) < 1e-9
|
||||
okem = abs(y["factors"]["equity_multiplier"]["value_exact"]
|
||||
- m["factors"]["equity_multiplier"]["value_exact"]) < 1e-9
|
||||
check("A3 年化=true:周转率/ROE ×12/月数、净利率/权益乘数不变(同量纲之比相消)",
|
||||
k == 12.0 and okat and okroe and oknpm and okem,
|
||||
f"scale={k} at={okat} roe={okroe} npm={oknpm} em={okem}")
|
||||
check("A3b 年化后恒等式仍 PASS", y.get("identity", {}).get("status") == "pass",
|
||||
f"identity={y.get('identity')}")
|
||||
|
||||
|
||||
def sec_a4(db):
|
||||
with Section("A4"):
|
||||
g = call(db, entity="hanke", period="2026-08", guard_probe="mismatch")
|
||||
check("A4 故意错配区间 → identity_broken 且不返回值(fail-closed)",
|
||||
g.get("identity", {}).get("status") == "identity_broken"
|
||||
and g.get("identity_broken") is True and g.get("roe") is None
|
||||
and "factors" in g,
|
||||
f"status={g.get('identity', {}).get('status')} roe={g.get('roe')} "
|
||||
f"diff={g.get('identity', {}).get('diff')}")
|
||||
|
||||
|
||||
def sec_a5_a6(db):
|
||||
for eid in (1, 2):
|
||||
with Section(f"A5 entity{eid}"):
|
||||
rows = db.execute(text("""SELECT kpi_code, data_source_type, status FROM kpi_definitions
|
||||
WHERE entity_id=:e AND kpi_code IN
|
||||
('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER')"""),
|
||||
{"e": eid}).fetchall()
|
||||
got = {r[0]: (r[1], r[2]) for r in rows}
|
||||
all_derived = (len(got) == 3 and all(v[0] == "derived" for v in got.values()))
|
||||
vals = db.execute(text("""SELECT COUNT(DISTINCT k.kpi_code) FROM kpi_values v
|
||||
JOIN kpi_definitions k ON k.id=v.kpi_id
|
||||
WHERE k.entity_id=:e AND k.kpi_code IN
|
||||
('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER')
|
||||
AND v.actual_value IS NOT NULL"""), {"e": eid}).scalar()
|
||||
check(f"A5 entity{eid} 三码均为 derived 且有 kpi_values(注册 {len(got)}/3,有值 {vals}/3)",
|
||||
all_derived and vals == 3, json.dumps(got, ensure_ascii=False))
|
||||
with Section("A6"):
|
||||
t = db.execute(text("""SELECT data_source_type FROM kpi_definitions
|
||||
WHERE entity_id=1 AND kpi_code='F_ASSET_TURNOVER'""")).scalar()
|
||||
check("A6 F_ASSET_TURNOVER data_source_type = derived", t == "derived", f"got={t}")
|
||||
|
||||
|
||||
def sec_a7(db):
|
||||
with Section("A7"):
|
||||
f = call(db, entity="hanke", start="2026-01", end="2026-08", period="custom")
|
||||
check("A7 期初缺失(2026-01 无 subject_balances 行)⇒ basis_fallback=true",
|
||||
f.get("basis_fallback") is True and f.get("basis") == "ending"
|
||||
and "期初" in (f.get("basis_fallback_reason") or ""),
|
||||
f"basis={f.get('basis')} reason={f.get('basis_fallback_reason')}")
|
||||
|
||||
|
||||
def sec_a8(db, m):
|
||||
if not m:
|
||||
check("A8 kpi_refs 镜像对比(前序 A2 未取到 payload,节略)", False, "")
|
||||
return
|
||||
with Section("A8"):
|
||||
refs = m.get("kpi_refs", {})
|
||||
exp = independent_recompute(db, 1, "2026-08", "2026-08", "3")
|
||||
checks = [
|
||||
("F_EQUITY_MULTIPLIER", refs.get("F_EQUITY_MULTIPLIER", {}).get("value"),
|
||||
m["factors"]["equity_multiplier"]["value_exact"], "端点权益乘数"),
|
||||
("F_ASSET_TURNOVER", refs.get("F_ASSET_TURNOVER", {}).get("value"),
|
||||
m["factors"]["asset_turnover"]["value_exact"], "端点周转率"),
|
||||
("F_ASSET_TOTAL", refs.get("F_ASSET_TOTAL", {}).get("value"),
|
||||
exp["total_assets_end"], "独立复算期末总资产"),
|
||||
("F_EQUITY_TOTAL", refs.get("F_EQUITY_TOTAL", {}).get("value"),
|
||||
exp["equity_end"], "独立复算期末净资产"),
|
||||
]
|
||||
drift = [f"{c}:{got}!={want}({src})" for c, got, want, src in checks
|
||||
if got is None or abs(float(got) - float(want)) > 0.02]
|
||||
check("A8 kpi_refs 派生值与端点/KPI 层同口径一致(乘数/周转率对端点,总量对独立复算;容差 0.02)",
|
||||
not drift, ";".join(drift) or "; ".join(
|
||||
f"{c}={g}" for c, g, _, _ in checks))
|
||||
|
||||
|
||||
def sec_a9(db):
|
||||
with Section("A9"):
|
||||
b = call(db, entity="bohai", period="2026-08", annualize=False)
|
||||
bexp = independent_recompute(db, 2, "2026-08", "2026-08", "4",
|
||||
revenue_codes=("6001", "6051", "6052"),
|
||||
net_profit_mode="pnl_net")
|
||||
okb = (abs((b["raw_data"]["revenue"] or 0) - bexp["revenue"]) < 1e-6
|
||||
and abs((b["raw_data"]["net_profit"] or 0) - bexp["net_profit"]) < 1e-6
|
||||
and abs((b["factors"]["equity_multiplier"]["value_exact"] or 0)
|
||||
- (bexp["equity_multiplier"] or 0)) < 1e-5)
|
||||
check("A9 entity2(新30号准则科目:4xxx权益/6xxx损益)独立复算一致", okb,
|
||||
f"rev={b['raw_data']['revenue']}/{bexp['revenue']} np={b['raw_data']['net_profit']}"
|
||||
f"/{bexp['net_profit']} em={b['factors']['equity_multiplier']['value_exact']}"
|
||||
f"/{bexp['equity_multiplier']}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--check", action="store_true")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
sec_a1(db)
|
||||
m = sec_a2(db)
|
||||
sec_a3(db, m)
|
||||
sec_a4(db)
|
||||
sec_a5_a6(db)
|
||||
sec_a7(db)
|
||||
sec_a8(db, m)
|
||||
sec_a9(db)
|
||||
except Exception: # 兜底(正常不应触发;各节已自行隔离)
|
||||
traceback.print_exc()
|
||||
check("顶层执行异常", False, traceback.format_exc().splitlines()[-1][:200])
|
||||
|
||||
passed = sum(1 for r in RESULTS if r["ok"])
|
||||
total = len(RESULTS)
|
||||
print(f"\n=== {passed}/{total} PASS ===")
|
||||
if args.json:
|
||||
print(json.dumps({"passed": passed, "total": total, "results": RESULTS},
|
||||
ensure_ascii=False, indent=1))
|
||||
db.close()
|
||||
return 0 if passed == total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user