fix(cma): cashflow statements importer + auto_verify/alert_generator observability

- add backend/scripts/import_cashflow_statements.py: load zxt cashflow stat (CNY) into cashflow_statements in 10k CNY, dry-run default, --apply gated

- auto_verify_cron: per-reason skip counters + legacy-condition rule now resolves kpi_code via plan.kpi_id (was unreachable) + --dry-run

- alert_generator: coverage self-check warns when current-month KPI values are absent (0 alerts != no trigger)

task: finance-20260916-cma-health-gaps-3items (items 1/2/3)
This commit is contained in:
Hermes CI Fix
2026-09-16 23:46:48 +08:00
parent 27d3e1cd73
commit 9b3a131070
3 changed files with 275 additions and 21 deletions
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""现金流量表(三大活动净额)入库器 —— 酣客(智享通) 现金流量统计表 → cashflow_statements
背景(任务 finance-20260916-cma-health-gaps-3items · 项1):
cashflow_statements 表 0 行 = **无导入链路**(此前只有 API 读/写端点,无任何 loader;
上游 智享通 数据虽已落盘 /root/WB_WS/_data/zxt-hanke/cashflow/<period>/,但从未入库)。
数据源(权威口径 = 智享通「现金流量统计表」项目级汇总,单位:元):
/root/WB_WS/_data/zxt-hanke/cashflow/<YYYY.MM>/cashflow_stat.csv
列:分类,编码,项目名称,方向,金额 分类∈{经营活动,投资活动,筹资活动}
兼容 2026.08 的另存名 cashflow_stat_<YYYY.MM>.csv
口径:
- 经营/投资/筹资 净额 = Σ(流入) − Σ(流出),单位 元 → 万元(÷10000,保留 2 位)
- 某活动无项目行 → 记 0.00(表列为 NOT NULL;同时写 remark 说明"无该项目=0")
- source='official'(官方报表口径);期初/期末现金 留空(主表采集缺陷,见报告 §项1-②)
- 同 (entity_id, period) 重复执行 = 覆盖更新(幂等)
用法:
python3 scripts/import_cashflow_statements.py # dry-run(默认,只算不写)
python3 scripts/import_cashflow_statements.py --period 2026-07 # 单期 dry-run
python3 scripts/import_cashflow_statements.py --apply # 落库(需放行)
"""
import argparse
import csv
import glob
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import CashFlowStructureValue
from app.api.cashflow_structure import classify_enterprise
ZKT_BASE = "/root/WB_WS/_data/zxt-hanke/cashflow"
CLASSES = ("经营活动", "投资活动", "筹资活动")
def _read_stat_file(path):
"""读项目级汇总 CSV → [(分类, 编码, 项目名称, 方向, 金额元)]"""
rows = []
with open(path, encoding="utf-8-sig") as fh:
for r in csv.reader(fh):
if len(r) < 5 or not r[0].strip() or r[1].strip() == "编码":
continue
try:
amt = float(r[4].strip().replace(",", ""))
except ValueError:
continue
rows.append((r[0].strip(), r[1].strip(), r[2].strip(), r[3].strip(), amt))
return rows
def _nets(rows):
agg = {c: {"in": 0.0, "out": 0.0} for c in CLASSES}
for cls, _code, _name, direction, amt in rows:
if cls not in agg:
continue
agg[cls]["in" if "流入" in direction else "out"] += amt
return {c: round(v["in"] - v["out"], 2) for c, v in agg.items()}, agg
def collect(period_filter=None):
"""扫描盘上各期 → [payload dict](万元,未写库)"""
payloads = []
for d in sorted(glob.glob(os.path.join(ZKT_BASE, "2026.*"))):
period = os.path.basename(d).replace(".", "-")
if period_filter and period != period_filter:
continue
cands = sorted(glob.glob(os.path.join(d, "cashflow_stat*.csv")))
# 优先取当期明细名(cashflow_stat_<period>.csv),其次通用名
src = None
for c in cands:
if os.path.basename(c) != "cashflow_stat.csv":
src = c
src = src or (cands[0] if cands else None)
if not src:
payloads.append({"period": period, "status": "SKIP_no_source_file", "dir": d})
continue
rows = _read_stat_file(src)
if not rows:
payloads.append({"period": period, "status": "SKIP_empty",
"src": src, "rows": 0})
continue
nets_yuan, agg = _nets(rows)
missing = [c for c in CLASSES if not agg[c]["in"] and not agg[c]["out"]]
remark = ("源: 智享通现金流量统计表 %s(单位元→万元 /10000);项目行 %d 条;缺项目活动: %s"
% (os.path.basename(src), len(rows), "、".join(missing) or "无"))
payloads.append({
"period": period,
"status": "READY",
"src": src,
"rows": len(rows),
"nets_yuan": nets_yuan,
"nets_wan": {k: round(v / 10000.0, 2) for k, v in nets_yuan.items()},
"missing_classes": missing,
"remark": remark,
})
return payloads
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--entity-id", type=int, default=1,
help="账套:1=酣客(白酒, 智享通通道) 2=博海(IT, 无凭证明细)")
ap.add_argument("--period", default=None, help="仅处理某期 YYYY-MM")
ap.add_argument("--apply", action="store_true", help="真正写库(缺省=dry-run 只算不写)")
args = ap.parse_args()
payloads = collect(args.period)
if not payloads:
print("未发现任何期数据源(%s/2026.*)" % ZKT_BASE)
return 2
mode = "APPLY" if args.apply else "DRY-RUN"
print("=== 现金流量表入库器 %s | entity_id=%d ===" % (mode, args.entity_id))
db = get_session_local()() if args.apply else None
written = 0
try:
for p in payloads:
if p["status"] != "READY":
print(" %-8s %s (%s)" % (p["period"], p["status"], p.get("src") or p.get("dir")))
continue
n = p["nets_wan"]
cls = classify_enterprise(n["经营活动"], n["投资活动"], n["筹资活动"])
print(" %-8s O=%8.2f I=%8.2f F=%8.2f 万 | 判定=%s | 源=%s"
% (p["period"], n["经营活动"], n["投资活动"], n["筹资活动"],
("类型%d %s" % (cls["type_no"], cls["name"])) if cls.get("classified")
else ("未判定: " + cls.get("reason", "")[:28]),
os.path.basename(p["src"])))
if not args.apply:
continue
rec = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == args.entity_id,
CashFlowStructureValue.period == p["period"],
).first()
created = rec is None
if rec is None:
rec = CashFlowStructureValue(entity_id=args.entity_id, period=p["period"])
db.add(rec)
rec.operating_net = n["经营活动"]
rec.investing_net = n["投资活动"]
rec.financing_net = n["筹资活动"]
rec.source = "official"
rec.remark = p["remark"]
db.flush()
written += 1
print(" → %s (id=%s)" % ("入库" if created else "更新", rec.id))
if args.apply:
db.commit()
print("=== 已提交 %d 期 ===" % written)
else:
print("=== dry-run 结束:未写库(%d 期可入库,加 --apply 落库)===" % len(
[p for p in payloads if p["status"] == "READY"]))
finally:
if db is not None:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())