feat(cma): 口径裁定 2026-09-17 落地(现金流投资=0 二维判定 / 预警单位+期间口径)
ci/woodpecker/push/main Pipeline failed

依据:wecom-finance/reports/CMA_口径裁定_20260917.md(任总 2026-09-17 23:14「按建议执行」)

裁定1(现金流「投资活动净额=0」参与判定)
- classify_enterprise():0 为合法输入;投资恒为 0 → 经营(±)×筹资(±) 二维映射出 2 个
  候选类型(candidate_type_nos/candidates),不再整体判「未判定」;投资栏 investing_note
  标注「本期无投资活动」;经营/筹资亦为 0 → 回退「未判定(无投资活动)」,不报错不静默
- /analysis 增 classification_dimension / investing_note / nets.investing.note;前端出
  二维横幅 + 候选行高亮 + 投资栏「本期无投资活动」(不给单一类型名)
- import_cashflow_statements.py:打印层修 2D(type_no=None)TypeError 崩溃;NA 措辞改为
  「按 0 参与判定」(机读短语 NA_PHRASE_TMPL/parse_na_activities 未动)

裁定2a(比率类单位统一「百分比(%)」口径,先做)
- 新增 app/utils/caliber_units.py:逐 KPI 对照表 RATIO_CALIBER_TABLE(唯一事实源)+ 判定侧
  唯一入口 resolve_caliber();3 项需归一 ×100(流动/速动/毛利率),12 项取数已百分数不归一;
  表外且命中「疑似错配」窄形态(阈值子句量级≥100 且 0<|实测|<10 且单位属比率类)→
  dimension_suspect:不落预警、不推送(唯一规则,防 586/587 假红灯复发)
- alert_generator:判定前归一,文案写「口径归一 ×100(原值 1.29 → 129%)」留审计线索
- 新增 scripts/kpi_unit_crosscheck.py(只读对照表产出器)

裁定2b(期间由「当月硬编码」→「最近可用期间」,后做)
- resolve_latest_period():取该 KPI 自己的最新 YYYY-MM(禁全局 MAX,防演示账套劫持);
  伪期间(2026-H1/H2、2025-FY)在 Python 侧过滤(保「单测=生产」语义)
- 过渡期覆盖度自检扩展:记录回落后的期间分布 + 当月 0 条归因提示
- --dry-run(零写入零推送)作放行前抽查证据;generate_and_push/run_alert_check 支持 dry_run

测试:backend/tests/test_caliber_rulings_20260917.py 17 项;全量 1158 passed/1 xfailed
This commit is contained in:
Hermes CI Fix
2026-09-18 00:34:04 +08:00
parent e567d1b94f
commit 3bb55189b1
8 changed files with 923 additions and 79 deletions
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""比率类 KPI 单位对照表(只读)—— 财务口径裁定 裁定2a 的产出器
用途:逐个比率类 KPI 核对「定义 / 声明单位 / 阈值量纲 / 实测值量纲」,出对照表。
判定侧的事实源是 `app/utils/caliber_units.RATIO_CALIBER_TABLE`(本脚本只核对与呈现,
不写库、不改阈值)。
用法:
python3 scripts/kpi_unit_crosscheck.py # 全账套
python3 scripts/kpi_unit_crosscheck.py --entity-id 1 # 单账套
python3 scripts/kpi_unit_crosscheck.py --json # JSON(机读)
"""
import argparse
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
from app.database import get_engine # noqa: E402
from app.utils.caliber_units import resolve_caliber # noqa: E402
PERIOD_RE = re.compile(r"^[0-9]{4}-[0-9]{2}$")
def q(sql, **params):
with get_engine().connect() as c:
return [dict(r._mapping) for r in c.execute(text(sql), params).fetchall()]
class _K(dict):
"""把 kpi 行包成 duck type(resolve_caliber 只读属性)"""
def __getattr__(self, item):
return self.get(item)
def latest_value(kpi_id):
rows = q("SELECT period, actual_value FROM kpi_values WHERE kpi_id=:k AND actual_value IS NOT NULL "
"ORDER BY period DESC, calculated_at DESC LIMIT 20", k=kpi_id)
for r in rows:
if PERIOD_RE.fullmatch(str(r["period"] or "")):
return r["period"], r["actual_value"]
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--entity-id", type=int, default=None)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
where = "WHERE d.status='active' AND (d.threshold_red IS NOT NULL OR d.threshold_yellow IS NOT NULL " \
"OR d.threshold_green IS NOT NULL)"
params = {}
if args.entity_id:
where += " AND d.entity_id=:e"
params["e"] = args.entity_id
rows = q("SELECT d.id, d.entity_id, d.kpi_code, d.kpi_name, d.unit, d.threshold_green, "
"d.threshold_yellow, d.threshold_red FROM kpi_definitions d %s "
"ORDER BY d.entity_id, d.id" % where, **params)
out = []
for r in rows:
p, v = latest_value(r["id"])
cal = resolve_caliber(v, _K(r))
out.append({
"kpi_code": r["kpi_code"], "kpi_name": r["kpi_name"], "entity_id": r["entity_id"],
"declared_unit": r["unit"],
"thresholds": "%s|%s|%s" % (r["threshold_green"], r["threshold_yellow"], r["threshold_red"]),
"latest_period": p, "actual": v,
"caliber_status": cal["status"], "norm_factor": cal["factor"],
"normalized": cal["value"], "note": cal["note"], "reason": cal["reason"],
})
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
return 0
print("=== 比率类 KPI 单位对照表(只读;裁定2a)===")
print("%-4s %-8s %-22s %-6s %-10s %-12s %-8s %-6s %s" % (
"eid", "code", "name", "unit", "latest", "actual", "status", "factor", "thresholds"))
for x in out:
print("%-4s %-8s %-22s %-6s %-10s %-12s %-18s %-6s %s" % (
x["entity_id"], x["kpi_code"], x["kpi_name"][:20], x["declared_unit"] or "-",
x["latest_period"] or "-", x["actual"] if x["actual"] is not None else "-",
x["caliber_status"], x["norm_factor"], x["thresholds"]))
n_norm = sum(1 for x in out if x["norm_factor"] != 1)
n_sus = sum(1 for x in out if x["caliber_status"] == "dimension_suspect")
print("=== 合计 %d 项:口径归一 ×100 %d 项 / 同量纲直判 %d 项 / 疑似错配降级待核 %d 项 ===" % (
len(out), n_norm, len(out) - n_norm - n_sus, n_sus))
return 0
if __name__ == "__main__":
sys.exit(main())