Files
cma-management/backend/scripts/import_cashflow_statements.py
T
Hermes CI Fix 293df6df8c feat(cma): cashflow NA annotation + dimension guard + threshold unit migration script
dev-level prep only: no prod write, no push, no pipeline deploy.
2026-09-17 00:01:58 +08:00

239 lines
11 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_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 位)
- **NA(无发生额)标注(本版本新增,裁定 finance-20260916-cma-caliber-ruling §2.1)**:
某活动**无任何项目行**时净额记 0.00(三净额列为 NOT NULL,表本身表达不了 NA),
同时在 `remark` 写入**固定短语**「<活动>活动本期无发生额(无项目行)」,
供判定层用 `parse_na_activities(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 --self-test # NA 标注逻辑自测(零库连接)
python3 scripts/import_cashflow_statements.py --apply # 落库(需放行)
"""
import argparse
import csv
import glob
import os
import re
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 = ("经营活动", "投资活动", "筹资活动")
#: NA 固定短语模板 —— 判定层与量纲守卫共用,勿改字面量(裁定 §2.1 指定)
NA_PHRASE_TMPL = "{cls}本期无发生额(无项目行)"
#: 从 remark 反解 NA 活动的正则(与 NA_PHRASE_TMPL 严格同源)
_NA_RE = re.compile(r"(经营活动|投资活动|筹资活动)本期无发生额(无项目行)")
def parse_na_activities(remark):
"""从 cashflow_statements.remark 取回 NA(无发生额)活动集合。
判定层入口:NA 维度**不参与**八类判定(降维),其 0.00 是"表表达不了 NA"的占位,
不是"收付相抵的 0"。同时提供 `na_flags(remark)` 返回 {活动: True/False} 供逐维判断。
"""
if not remark:
return set()
return set(_NA_RE.findall(str(remark)))
def na_flags(remark):
"""逐维 NA 标志:{'经营活动': bool, '投资活动': bool, '筹资活动': bool}"""
na = parse_na_activities(remark)
return {c: (c in na) for c in 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"]]
na_phrases = [NA_PHRASE_TMPL.format(cls=c) for c in missing]
head = "源: 智享通现金流量统计表 %s(单位元→万元 /10000);项目行 %d 条" % (
os.path.basename(src), len(rows))
remark = (head + ";NA(无发生额,不参与判定): " + "、".join(na_phrases)) if na_phrases \
else (head + ";三类活动均有项目行(无 NA)")
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,
"na_classes": missing,
"na_phrases": na_phrases,
"na_flags": {c: (c in missing) for c in CLASSES},
"remark": remark,
})
return payloads
def _self_test():
"""NA 标注逻辑自测(零库连接、零写盘;只读盘上 CSV)——回执证据用。"""
passed = 0
# 1~3) 短语 → 标志 反解
for missing, expect in (
(["投资活动"], {"经营活动": False, "投资活动": True, "筹资活动": False}),
(["投资活动", "筹资活动"], {"经营活动": False, "投资活动": True, "筹资活动": True}),
([], {"经营活动": False, "投资活动": False, "筹资活动": False}),
):
phrases = [NA_PHRASE_TMPL.format(cls=c) for c in missing]
remark = ("源: X;项目行 3 条;NA(无发生额,不参与判定): " + "、".join(phrases)) if phrases \
else "源: X;项目行 3 条;三类活动均有项目行(无 NA)"
assert na_flags(remark) == expect, (missing, remark)
assert parse_na_activities(remark) == set(missing)
passed += 1
# 4) 空值安全
assert na_flags(None) == {c: False for c in CLASSES}
assert parse_na_activities("") == set()
passed += 1
# 5) 真实数据源(盘上 8 期):投资活动应全为 NA
pay = collect()
ready = [p for p in pay if p["status"] == "READY"]
assert ready, "无可入库期(数据源缺失)"
all_inv_na = all("投资活动" in p["na_classes"] for p in ready)
passed += 1
print("=== NA 标注 self-test: %d/5 PASS | 期数=%d | 投资活动全NA=%s ==="
% (passed, len(ready), all_inv_na))
for p in ready:
print(" %-8s NA=%s" % (p["period"], "、".join(p["na_classes"]) or "无"))
print(" remark: %s" % p["remark"])
assert all_inv_na, "酣客 8 期投资活动应全为 NA(裁定 §2.1 实证)"
return 0
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("--self-test", action="store_true",
help="NA 标注逻辑自测(零库连接:只读盘上 CSV)")
ap.add_argument("--apply", action="store_true", help="真正写库(缺省=dry-run 只算不写)")
args = ap.parse_args()
if args.self_test:
return _self_test()
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"])))
print(" NA(无发生额,不参与判定)=%s" % ("、".join(p["na_classes"]) or "无"))
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())