feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
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:
Hermes CI Fix
2026-10-04 13:35:25 +08:00
parent 1f1385ebbf
commit 38a6de9325
7 changed files with 1435 additions and 166 deletions
+92
View File
@@ -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())
+183
View File
@@ -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())