#!/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//,但从未入库)。 数据源(权威口径 = 智享通「现金流量统计表」项目级汇总,单位:元): /root/WB_WS/_data/zxt-hanke/cashflow//cashflow_stat.csv 列:分类,编码,项目名称,方向,金额 分类∈{经营活动,投资活动,筹资活动} 兼容 2026.08 的另存名 cashflow_stat_.csv 期初/期末现金数据源(本版本新增,任务 project-20260923-cashflow-beginend-cash-backfill): /root/WB_WS/_data/zxt-hanke//kmyeb.csv(科目余额表,只读) 取 科目编码 ∈ {1001 库存现金, 1002 银行存款, 1012 其他货币资金} 期初 = Σ(期初借 − 期初贷);期末 = Σ(期末借 − 期末贷);元 → 万元(÷10000,2 位) 依据:科目余额表货币资金逐期变动 == 库内三大活动净额合计(8/8 期吻合 ±0.01, 2026-09-23 交叉验证);上游「现金流量表主表」(ReportDesign 1405)金额实测全 0 不可用 ⇒ 主表口径(历史 §项1-② 缺陷)不改,改走科目余额表货币资金。 该源属**智享通通道 = 酣客账套**(KMYEB_ENTITY_ID),故仅对 entity_id=1 生效; 其它账套保持 NULL(不得把酣客货币资金写到别家账套)。 口径: - 经营/投资/筹资 净额 = Σ(流入) − Σ(流出),单位 元 → 万元(÷10000,保留 2 位) - **NA(无发生额)标注(本版本新增,裁定 finance-20260916-cma-caliber-ruling §2.1)**: 某活动**无任何项目行**时净额记 0.00(三净额列为 NOT NULL,表本身表达不了 NA), 同时在 `remark` 写入**固定短语**「<活动>活动本期无发生额(无项目行)」, 供判定层用 `parse_na_activities(remark)` 取回 → **仅作标注与机读入口**; 口径裁定(2026-09-17)已定:该维按 **0 如实参与判定**(八类判定的**二维映射**, 经营(±)×筹资(±) 出 2 个候选类型),**不再**因 NA/0 整体判「未判定」。 - source='official'(官方报表口径) - **期初/期末现金**(本版本新增):取科目余额表货币资金(1001/1002/1012)→ 万元 2 位, 源文件/科目缺失时保持 NULL(不写 0 造数);仅 entity_id=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 # 标注+期初期末自测(零库连接; # 边界用例只在系统临时目录造样,不碰生产源) python3 scripts/import_cashflow_statements.py --apply # 落库(需放行) """ import argparse import csv import glob import os import re import sys import tempfile 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 = ("经营活动", "投资活动", "筹资活动") #: 科目余额表(kmyeb.csv)根目录 —— 与 cashflow/ 同级,期初/期末现金来源 KMYEB_BASE = "/root/WB_WS/_data/zxt-hanke" #: 货币资金科目:库存现金 / 银行存款 / 其他货币资金 CASH_CODES = ("1001", "1002", "1012") #: kmyeb 属智享通通道 = 酣客账套;其它账套不得套用(防跨账套写脏) KMYEB_ENTITY_ID = 1 #: 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";但按 2026-09-17 口径裁定,两者**都按 0 参与判定** (投资活动=0 → 二维映射出 2 个候选类型),NA 短语仅用于页面/报告显式标注。 `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 _read_cash_balances(period, base=KMYEB_BASE): """读科目余额表货币资金 → (begin_wan, end_wan, meta)。 期初 = Σ(期初借 − 期初贷)、期末 = Σ(期末借 − 期末贷),科目 ∈ CASH_CODES,元 → 万元 2 位。 借方−贷方为**有符号**口径:贷方大于借方即为负(红字/透支/方向反),**不得取绝对值或清零**。 源文件缺失 / 无任一货币资金科目行 ⇒ 返回 (None, None, meta),由调用方保持 NULL(不写 0 造数)。 """ day_dir = os.path.join(base, period.replace("-", ".")) src = os.path.join(day_dir, "kmyeb.csv") if not os.path.exists(src): return None, None, {"status": "SKIP_no_kmyeb", "src": src, "codes": []} beg = 0.0 end = 0.0 hits = [] with open(src, encoding="utf-8-sig") as fh: for r in csv.DictReader(fh): code = (r.get("科目编码") or "").strip() if code not in CASH_CODES: continue try: b = float((r.get("期初借") or "0").strip() or 0) - float((r.get("期初贷") or "0").strip() or 0) e = float((r.get("期末借") or "0").strip() or 0) - float((r.get("期末贷") or "0").strip() or 0) except ValueError: continue hits.append(code) beg += b end += e if not hits: return None, None, {"status": "SKIP_no_cash_subject", "src": src, "codes": []} return (round(beg / 10000.0, 2), round(end / 10000.0, 2), {"status": "READY", "src": src, "codes": hits}) def _write_kmyeb(root, period, rows): """自测专用:在临时目录造 kmyeb.csv 样件(表头与生产源同构)。""" d = os.path.join(root, period.replace("-", ".")) os.makedirs(d, exist_ok=True) p = os.path.join(d, "kmyeb.csv") with open(p, "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["科目编码", "科目名称", "期初借", "期初贷", "本期借", "本期贷", "本年借", "本年贷", "当前借", "当前贷", "期末借", "期末贷"]) w.writerows(rows) return p 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_.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(无发生额,按0参与判定): " + "、".join(na_phrases)) if na_phrases \ else (head + ";三类活动均有项目行(无 NA)") cash_begin, cash_end, cash_meta = _read_cash_balances(period) 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, "begin_cash": cash_begin, "end_cash": cash_end, "cash_meta": cash_meta, }) 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(无发生额,按0参与判定): " + "、".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 assert all_inv_na, "酣客 8 期投资活动应全为 NA(裁定 §2.1 实证)" # 6~8) 期初/期末现金取数边界(临时目录造样,零生产源写) with tempfile.TemporaryDirectory() as tmp: # 6) kmyeb 缺失 → 保持 NULL(不得写 0) b6, e6, m6 = _read_cash_balances("2026-01", base=tmp) assert (b6, e6) == (None, None) and m6["status"] == "SKIP_no_kmyeb", (b6, e6, m6) passed += 1 # 7) 文件在但无货币资金科目(非现金科目不得混入) _write_kmyeb(tmp, "2026-01", [ ["1122", "应收账款", "999", "0", "0", "0", "0", "0", "0", "0", "999", "0"]]) b7, e7, m7 = _read_cash_balances("2026-01", base=tmp) assert (b7, e7) == (None, None) and m7["status"] == "SKIP_no_cash_subject", (b7, e7, m7) passed += 1 # 8) 借贷为负(贷>借):负号必须保留,不得 abs/清零;非现金科目应被忽略 _write_kmyeb(tmp, "2026-02", [ ["1001", "库存现金", "0", "5000", "0", "0", "0", "0", "0", "0", "0", "12000"], ["1002", "银行存款", "20000", "0", "0", "0", "0", "0", "0", "0", "30000", "0"], ["1122", "应收账款", "999", "0", "0", "0", "0", "0", "0", "0", "999", "0"]]) b8, e8, m8 = _read_cash_balances("2026-02", base=tmp) assert (b8, e8) == (1.5, 1.8), (b8, e8) assert m8["status"] == "READY" and sorted(m8["codes"]) == ["1001", "1002"], m8 _write_kmyeb(tmp, "2026-03", [ ["1001", "库存现金", "0", "12000", "0", "0", "0", "0", "0", "0", "0", "12000"]]) b8b, e8b, _m = _read_cash_balances("2026-03", base=tmp) assert (b8b, e8b) == (-1.2, -1.2), (b8b, e8b) passed += 1 # 9) 真实源 8 期:期初/期末齐全 + 与三大净额自洽(期末−期初 == Σ净额 ±0.02)+ 跨期连续(±0.01) with_cash = [p for p in ready if p["begin_cash"] is not None and p["end_cash"] is not None] assert len(with_cash) == len(ready), "部分期未取到货币资金:%d/%d" % (len(with_cash), len(ready)) for p in ready: s = round(sum(p["nets_wan"][c] for c in CLASSES), 2) d = round(p["end_cash"] - p["begin_cash"], 2) assert abs(d - s) <= 0.02, ("期末−期初≠净额合计", p["period"], d, s) prev_end = None for p in ready: if prev_end is not None: assert abs(p["begin_cash"] - prev_end) <= 0.01, ( "跨期不连续", p["period"], p["begin_cash"], prev_end) prev_end = p["end_cash"] passed += 1 print("=== self-test: %d/9 PASS | 期数=%d | 投资活动全NA=%s | 期初/期末齐全=%d/%d ===" % (passed, len(ready), all_inv_na, len(with_cash), len(ready))) for p in ready: print(" %-8s NA=%s | 期初=%.2f 期末=%.2f 万(Δ=%.2f / 净额合计=%.2f)" % (p["period"], "、".join(p["na_classes"]) or "无", p["begin_cash"], p["end_cash"], round(p["end_cash"] - p["begin_cash"], 2), round(sum(p["nets_wan"][c] for c in CLASSES), 2))) print(" remark: %s" % p["remark"]) 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["筹资活动"]) # 裁定1(2026-09-17):投资活动=0 → dimension="2D"(type_no=None,候选 2 个) # ⇒ 打印层不得假定 classified ⇒ type_no 非空(原实现此处 TypeError 崩掉整轮 dry-run) if cls.get("classified"): if cls.get("dimension") == "2D": verdict = "二维判定(%s) → 候选 %s" % ( cls.get("dimension_label") or "投资活动=0", " / ".join("类型%d %s" % (c["type_no"], c["name"]) for c in cls.get("candidates") or [])) else: verdict = "类型%d %s" % (cls["type_no"], cls["name"]) else: verdict = "未判定: " + cls.get("reason", "")[:28] print(" %-8s O=%8.2f I=%8.2f F=%8.2f 万 | 判定=%s | 源=%s" % (p["period"], n["经营活动"], n["投资活动"], n["筹资活动"], verdict, os.path.basename(p["src"]))) print(" NA(无发生额)=%s;投资栏标注=%s" % ( "、".join(p["na_classes"]) or "无", cls.get("investing_note") or "-")) cash_applicable = (args.entity_id == KMYEB_ENTITY_ID) has_cash = p["begin_cash"] is not None and p["end_cash"] is not None print(" 期初/期末现金 = %s / %s 万(源 %s)%s" % ( "NULL" if p["begin_cash"] is None else "%.2f" % p["begin_cash"], "NULL" if p["end_cash"] is None else "%.2f" % p["end_cash"], os.path.basename(p["cash_meta"]["src"]), "" if cash_applicable else ";账套 %d 非智享通通道 → 不写期初/期末" % args.entity_id)) 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"] if cash_applicable and has_cash: # 期初/期末现金:科目余额表货币资金(已 ÷10000 留 2 位) rec.begin_cash = p["begin_cash"] rec.end_cash = p["end_cash"] db.flush() written += 1 print(" → %s (id=%s) 期初/期末=%s" % ( "入库" if created else "更新", rec.id, "NULL(源缺失,保持原值)" if not (cash_applicable and has_cash) else "%.2f / %.2f 万" % (rec.begin_cash, rec.end_cash))) 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())