feat(cma): cashflow NA annotation + dimension guard + threshold unit migration script

dev-level prep only: no prod write, no push, no pipeline deploy.
This commit is contained in:
Hermes CI Fix
2026-09-17 00:01:58 +08:00
parent 9b3a131070
commit 293df6df8c
3 changed files with 503 additions and 3 deletions
+78 -3
View File
@@ -12,19 +12,25 @@
口径:
- 经营/投资/筹资 净额 = Σ(流入) − Σ(流出),单位 元 → 万元(÷10000,保留 2 位)
- 某活动无项目行 → 记 0.00(表列为 NOT NULL;同时写 remark 说明"无该项目=0")
- **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__))))
@@ -36,6 +42,29 @@ 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 → [(分类, 编码, 项目名称, 方向, 金额元)]"""
@@ -85,8 +114,11 @@ def collect(period_filter=None):
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 "无"))
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",
@@ -95,19 +127,61 @@ def collect(period_filter=None):
"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)
@@ -129,6 +203,7 @@ def main():
("类型%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(