Files
cma-management/backend/scripts/verify_cashflow_direct_caliber.py
T
Hermes CI Fix 45832cbf45 feat(kpi): collect cashflow-direct caliber for F_OP_CFLOW/F_FCF
- kpi_value_collector: new calc=cashflow_direct branch (line-level counterpart

  classification: cash vouchers -> operating/financing/investing buckets),

  no hardcoded voucher numbers; optional minus_capex for FCF

- verify_cashflow_direct_caliber.py: 8-period reconcile + identity + negative

  control (voucher-level classification would overstate 2026-06 by 18,700)

task: yanxue-budget-cashflow-kpi-activate-20260914
2026-09-14 18:01:57 +08:00

131 lines
6.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
"""回归电池: 现金流量表直接法口径(cashflow_direct)—— KPI4 F_OP_CFLOW / KPI45 F_FCF。
任务: yanxue-budget-cashflow-kpi-activate-20260914(财务口径说明 §二/§三/§五 落地复核)
判据(全部只读,write_kpi=False):
1. 生产同一代码路径试算 2026-01..08,与现网 zxt-sync 权威值对账(容差 0.001)
—— 2026-08 因上游已撤单凭证仍在库内(智享通快照不含)按已知差异放行(WARN,不算 FAIL)
2. 恒等式: 经营净额 + 筹资净额 + 投资净额 = 现金总净额(每期 |Δ| ≤ 0.001)
3. 负例对照: 若按「凭证级整单分类」(错误做法),2026-06 应为 453,697.63 元
(行级 = 434,997.63 元,差 +18,700 = 凭证 31226 同单内的银行手续费)→ 证明必须行级拆分
4. F_FCF = 经营净额 − 资本支出;酣客 2026 年 capex=0 → 与经营净额同值
用法: /usr/bin/python3 backend/scripts/verify_cashflow_direct_caliber.py (退出码 0 = 全部通过)
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import get_session_local
from app.models import KPIValueSource, KPIValue
from scripts.kpi_value_collector import collect_for_mapping
ENTITY = 1
PERIODS = ["2026-0%d" % i for i in range(1, 9)]
TOL = 0.001
KNOWN_DIFF_PERIODS = {"2026-08"} # 上游已撤单凭证未标记 → 已知差异(挂账,非回归)
RULE = {
"calc": "cashflow_direct", "measure": "net", "version": 1, "bucket": "operating",
"cash_subjects": ["1001%", "1002%", "1012%"],
"financing_counterpart": ["2001", "2501", "2502", "3001", "3002", "3101", "3103", "3104", "2232", "2241"],
"investing_counterpart": ["1501", "1511", "1601", "1602", "1603", "1604", "1605", "1606", "1701", "1702"],
"round": 4,
}
CAPEX = {"counterpart": ["1601", "1604", "1605", "1606", "1701", "1702"], "side": "debit"}
CASH_PREFIX = ("1001", "1002", "1012")
FIN = tuple(RULE["financing_counterpart"])
INV = tuple(RULE["investing_counterpart"])
fails, warns = [], []
def probe(db, kpi_id, period, rule):
m = KPIValueSource(entity_id=ENTITY, kpi_id=kpi_id, source_table="voucher_details",
source_field="credit_amount", aggregate="sum", filter_rule=rule,
period_field="period", unit_conversion=0.0001, status="active")
return collect_for_mapping(db, m, period, write_kpi=False)
def ref(db, kpi_id, period):
row = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.period == period,
KPIValue.source_type == "zxt-sync", KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
return float(row.actual_value) if row else None
def main():
db = get_session_local()()
try:
print("== 判据1: 8 期对账(现网 zxt-sync)==")
for p in PERIODS:
for kpi in (4, 45):
rule = dict(RULE, minus_capex=CAPEX) if kpi == 45 else dict(RULE)
val, _ = probe(db, kpi, p, rule)
r = ref(db, kpi, p)
ok = val is not None and r is not None and abs(val - r) <= TOL
tag = "PASS" if ok else ("WARN" if p in KNOWN_DIFF_PERIODS else "FAIL")
print(f" {tag} KPI{kpi} {p}: 算={val} 现网={r}")
if not ok:
(warns if p in KNOWN_DIFF_PERIODS else fails).append(f"KPI{kpi} {p}")
print("\n== 判据2: 恒等式(经营+筹资+投资=现金总净额)& 判据4: FCF=capex扣减 ==")
for p in PERIODS:
op, _ = probe(db, 4, p, dict(RULE, bucket="operating"))
fin, _ = probe(db, 4, p, dict(RULE, bucket="financing"))
inv, _ = probe(db, 4, p, dict(RULE, bucket="investing"))
net, _ = probe(db, 4, p, dict(RULE, bucket="net_cash"))
fcf, _ = probe(db, 45, p, dict(RULE, minus_capex=CAPEX))
f = lambda x: float(x) if x is not None else 0.0
ident = abs(f(op) + f(fin) + f(inv) - f(net))
same = abs(f(fcf) - f(op)) <= TOL # 酣客 capex=0 → FCF ≡ 经营净额
ok = ident <= TOL and same
print(f" {'PASS' if ok else 'FAIL'} {p}: 经营={op} 筹资={fin} 投资={inv} 现金净额={net} "
f"identityΔ={round(ident, 4)} FCF={fcf}")
if not ok:
fails.append(f"identity/{p}")
print("\n== 判据3: 负例对照(凭证级整单分类 = 错误做法)==")
sql = text("""
SELECT v.voucher_no, v.subject_code, v.debit_amount, v.credit_amount
FROM voucher_details v
JOIN (SELECT DISTINCT entity_id, voucher_date, voucher_no, period FROM voucher_details
WHERE entity_id=:e AND period=:p
AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%' OR subject_code LIKE '1012%')) w
ON w.entity_id=v.entity_id AND w.voucher_date=v.voucher_date
AND w.voucher_no=v.voucher_no AND w.period=v.period
WHERE v.entity_id=:e AND v.period=:p""")
rows = db.execute(sql, {"e": ENTITY, "p": "2026-06"}).fetchall()
line_op = vlevel_op = 0.0
per_voucher = {}
for vno, code, dr, cr in rows:
code = str(code)
n = float(cr) - float(dr)
per_voucher.setdefault(vno, []).append((code, n))
if code.startswith(CASH_PREFIX):
continue
if code[:4] not in FIN and code[:4] not in INV:
line_op += n
for vno, lines in per_voucher.items():
cps = [(c, n) for c, n in lines if not c.startswith(CASH_PREFIX)]
if any(c[:4] in FIN or c[:4] in INV for c, _ in cps):
continue
vlevel_op += sum(n for _, n in cps)
ok = abs(line_op - 434997.63) <= 0.01 and abs(vlevel_op - line_op - 18700.0) <= 0.01
print(f" {'PASS' if ok else 'FAIL'} 2026-06 行级={round(line_op, 2)} 凭证级={round(vlevel_op, 2)} "
f"Δ={round(vlevel_op - line_op, 2)}(预期行级 434,997.63 / Δ +18,700.00)")
if not ok:
fails.append("negative-control-2026-06")
print(f"\n===== 汇总: {'全部通过' if not fails else 'FAIL ' + str(len(fails))} "
f"| WARN(已知差异) {len(warns)} 项 {warns if warns else ''} =====")
return 1 if fails else 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())