Files
cma-management/backend/scripts/migrate_kpi_threshold_unit_20260916.py
T
Hermes CI Fix 3bb55189b1
ci/woodpecker/push/main Pipeline failed
feat(cma): 口径裁定 2026-09-17 落地(现金流投资=0 二维判定 / 预警单位+期间口径)
依据: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
2026-09-18 00:34:04 +08:00

227 lines
12 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
# -*- coding: utf-8 -*-
"""阈值单位口径修正 + 量纲守卫装配(迁移脚本·准备态)
⚠️ **已被取代,不得执行(2026-09-17)**:
本脚本基于 2026-09-16 的口径草案(**把阈值改成「倍」写法** >=2.0/>=1.0/<1.0)。
2026-09-17 财务**正式裁定**(`wecom-finance/reports/CMA_口径裁定_20260917.md` 裁定2a)
采用**相反方向**:比率类统一「百分比(%)」口径、**阈值保持不变**(150/80),
在**取数层** ×100 归一(实现:`app/utils/caliber_units.py` + `scripts/alert_generator.py`)。
⇒ 执行本脚本会把口径反向切回「倍」,与现行裁定冲突;假红灯 586/587 在新口径下
**是合法红灯**(流动比率 1.29=129% < 150,与「流动性偏紧」一致),**不得作废**。
保留本文件仅为审计链(历史草案)——如未来另有阈值单位新裁定,须先改 `PLAN` 与
`--resolve-false-reds` 的语义再执行。
原依据:财务口径裁定 finance-20260916-cma-caliber-ruling.md v1.0 §四(裁定③)+ §3.3/§3.4
· 4.1 单位规范:偿债能力比率=**倍**(无量纲);资产负债率等=%;返利率=%
· 4.3 具体阈值裁定(贸易型:白酒经销 / IT 经销)
· 3.3 09-09 假红灯 586/587 按「口径错误」作废并重算
· 3.4 切换顺序不可颠倒:先单位 → 再期间 → 后调度(本脚本只做第 1 步)
默认 dry-run(只读:列改动 + 影响面复算,不写库)。`--apply` 才写(**需审批**)。
用法:
python3 scripts/migrate_kpi_threshold_unit_20260916.py # dry-run
python3 scripts/migrate_kpi_threshold_unit_20260916.py --apply # 写阈值/单位(需放行)
python3 scripts/migrate_kpi_threshold_unit_20260916.py --apply --resolve-false-reds
# 同时作废假红灯 586/587
说明:
· 影响面口径 = 用阈值复算「最近可用期间(<= 最新已入库月)」的命中条数,
分别给「修正前 / 修正后」两组数字(裁定 §4.5 要求:影响面以修正后复算值为准)。
· 返利率(F_REBATE_RATE)**不改阈值**(量纲一致、判定有效),仅标注口径待确认(裁定 §4.4)。
"""
import argparse
import sys
import os
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.dimension_guard import check_dimension # noqa: E402
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY # noqa: E402
ENTITY_ID = 1 # 酣客(白酒经销);博海(IT)无阈值 KPI 待核
#: 迁移计划:kpi_code → 目标 unit / 三档阈值(None = 不改该字段)
PLAN = {
"F_CURRENT_RATIO": {"name": "流动比率", "unit": "倍",
"green": ">=2.0", "yellow": ">=1.0", "red": "<1.0",
"why": "裁定 §4.3:经验值 2:1;<1 才够紧急(旧 <150 为百分数写法)"},
"F_QUICK_RATIO": {"name": "速动比率", "unit": "倍",
"green": ">=1.0", "yellow": ">=0.5", "red": "<0.5",
"why": "裁定 §4.3:经验值 1:1(旧 <80 为百分数写法)"},
"F_DEBT_RATIO": {"name": "资产负债率", "unit": "%",
"green": "<=60", "yellow": ">60", "red": ">70",
"why": "裁定 §4.3:红 >70 / 黄 60–70 / 绿 ≤60"},
}
#: 明确不改、但需标注的项
UNCHANGED_NOTES = {
"F_REBATE_RATE": ("返利率", "%", "裁定 §4.4:量纲一致、判定有效,不改阈值;"
"48.44% 口径本身待确认 → 预警保留并标注「口径待确认」"),
}
#: 假红灯(09-09 单位错配产生)—— 裁定 §3.3:按「口径错误」作废
FALSE_RED_ALERT_IDS = (586, 587)
VOID_RESOLUTION = "[口径错误·作废] 阈值与实测值跨量级比较(流动/速动比率单位错配)," \
"非财务事实;由 finance-20260916-cma-caliber-ruling §3.3 裁定作废,待单位修正后重算。"
def q(sql, params=None):
with get_engine().connect() as c:
return [dict(r._mapping) for r in c.execute(text(sql), params or {}).fetchall()]
def exec_sql(sql, params=None):
with get_engine().begin() as c:
return c.execute(text(sql), params or {})
def fetch_plan_rows():
codes = list(PLAN.keys()) + list(UNCHANGED_NOTES.keys())
ph = ",".join(":%s" % ("c%d" % i) for i in range(len(codes)))
return q("SELECT id, kpi_code, kpi_name, unit, threshold_green, threshold_yellow, threshold_red, status "
"FROM kpi_definitions WHERE entity_id=:e AND kpi_code IN (%s) ORDER BY id" % ph,
dict({"e": ENTITY_ID}, **{("c%d" % i): c for i, c in enumerate(codes)}))
def latest_period():
r = q("SELECT MAX(period) AS p FROM kpi_values WHERE period REGEXP '^[0-9]{4}-[0-9]{2}$'")
return r[0]["p"] if r else None
def impact():
"""影响面复算:最近可用期间上,各 KPI 在「修正前 / 修正后」的命中条数。"""
p = latest_period()
rows = q(
"SELECT d.id, d.kpi_code, d.kpi_name, d.unit, d.threshold_green AS g, "
"d.threshold_yellow AS y, d.threshold_red AS r, v.actual_value AS actual, v.period "
"FROM kpi_definitions d JOIN kpi_values v ON v.kpi_id = d.id "
"WHERE d.entity_id=:e AND d.status='active' AND v.period = :p AND v.actual_value IS NOT NULL "
"AND (d.threshold_red IS NOT NULL OR d.threshold_yellow IS NOT NULL OR d.threshold_green IS NOT NULL) "
"ORDER BY d.id", {"e": ENTITY_ID, "p": p})
def judge(actual, unit, g, y, r):
exprs = {"green": g, "yellow": y, "red": r}
guard_hit = None
for lv in LEVEL_PRIORITY:
if exprs[lv]:
gd = check_dimension(actual, exprs[lv], unit)
if not gd["ok"]:
guard_hit = gd
break
if guard_hit:
return "口径错误"
for lv in LEVEL_PRIORITY:
if exprs[lv] and eval_threshold(actual, exprs[lv]):
return lv
return "info"
before, after, detail = {"red": 0, "yellow": 0, "green": 0, "info": 0, "口径错误": 0}, \
{"red": 0, "yellow": 0, "green": 0, "info": 0, "口径错误": 0}, []
for x in rows:
plan = PLAN.get(x["kpi_code"])
g2 = plan["green"] if plan else x["g"]
y2 = plan["yellow"] if plan else x["y"]
r2 = plan["red"] if plan else x["r"]
u2 = plan["unit"] if plan else x["unit"]
b = judge(x["actual"], x["unit"], x["g"], x["y"], x["r"])
a = judge(x["actual"], u2, g2, y2, r2)
before[b] = before.get(b, 0) + 1
after[a] = after.get(a, 0) + 1
if b != a:
detail.append({"kpi": "%s/%s" % (x["kpi_code"], x["kpi_name"]), "period": x["period"],
"actual": x["actual"], "unit": x["unit"],
"before": b, "after": a,
"thresholds": "%s|%s|%s → %s|%s|%s" % (x["g"], x["y"], x["r"], g2, y2, r2)})
return p, before, after, detail
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="真正写库(缺省=dry-run)")
ap.add_argument("--resolve-false-reds", action="store_true",
help="同时把假红灯 %s 按口径错误作废(status=resolved)" % (FALSE_RED_ALERT_IDS,))
args = ap.parse_args()
print("=== 阈值单位口径修正 %s | entity_id=%d ===" % ("APPLY" if args.apply else "DRY-RUN", ENTITY_ID))
rows = fetch_plan_rows()
if not rows:
print("!! 未取到目标 KPI 行(kpi_code 不匹配?)")
return 2
print("\n-- 计划改动 --")
todo = []
for x in rows:
plan = PLAN.get(x["kpi_code"])
if not plan:
note = UNCHANGED_NOTES.get(x["kpi_code"])
print(" [不改] id=%-4s %-6s %-8s unit=%s 阈值=%s|%s|%s ← %s"
% (x["id"], x["kpi_code"], x["kpi_name"], x["unit"],
x["threshold_green"], x["threshold_yellow"], x["threshold_red"],
note[2] if note else ""))
continue
changed = (x["unit"] != plan["unit"] or x["threshold_green"] != plan["green"]
or x["threshold_yellow"] != plan["yellow"] or x["threshold_red"] != plan["red"])
print(" [%s] id=%-4s %-6s %-8s unit %s→%s 阈值 %s|%s|%s → %s|%s|%s"
% ("改" if changed else "已符", x["id"], x["kpi_code"], x["kpi_name"],
x["unit"], plan["unit"], x["threshold_green"], x["threshold_yellow"], x["threshold_red"],
plan["green"], plan["yellow"], plan["red"]))
print(" 理由: %s" % plan["why"])
if changed:
todo.append((x["id"], plan))
print("\n-- 影响面复算(最近可用期间) --")
p, before, after, detail = impact()
print(" 期间 P* = %s" % p)
print(" 修正前: 红 %d / 黄 %d / 绿 %d / 无判定 %d / 口径错误 %d"
% (before["red"], before["yellow"], before["green"], before["info"], before["口径错误"]))
print(" 修正后: 红 %d / 黄 %d / 绿 %d / 无判定 %d / 口径错误 %d"
% (after["red"], after["yellow"], after["green"], after["info"], after["口径错误"]))
print(" 条数变化(仅列变化项):")
for d in detail:
print(" %-22s %s actual=%s unit=%s %s → %s [%s]"
% (d["kpi"], d["period"], d["actual"], d["unit"], d["before"], d["after"], d["thresholds"]))
print(" ※ 影响面以「修正后」为准(裁定 §4.5);此处红/黄为静态阈值复算上界,"
"实际落库条数还受期间口径与幂等约束。")
if args.resolve_false_reds:
print("\n-- 假红灯作废 --")
ph = ",".join(":a%d" % i for i in range(len(FALSE_RED_ALERT_IDS)))
al = q("SELECT id, kpi_id, alert_level, status, alert_message FROM kpi_alerts "
"WHERE id IN (%s)" % ph,
{("a%d" % i): v for i, v in enumerate(FALSE_RED_ALERT_IDS)})
for a in al:
print(" id=%s level=%s status=%s msg=%s" % (a["id"], a["alert_level"], a["status"],
(a["alert_message"] or "")[:70]))
if not al:
print(" (未取到该两条预警,可能已清理)")
if not args.apply:
print("\n=== dry-run 结束:未写库(%d 项待改,加 --apply 落库 / 需审批)===" % len(todo))
return 0
for kid, plan in todo:
exec_sql("UPDATE kpi_definitions SET unit=:u, threshold_green=:g, threshold_yellow=:y, "
"threshold_red=:r WHERE id=:i AND entity_id=:e",
{"u": plan["unit"], "g": plan["green"], "y": plan["yellow"],
"r": plan["red"], "i": kid, "e": ENTITY_ID})
if args.resolve_false_reds:
exec_sql("UPDATE kpi_alerts SET status='resolved', resolution=:res, resolved_at=NOW() "
"WHERE id IN (%s)" % ",".join(":a%d" % i for i in range(len(FALSE_RED_ALERT_IDS))),
dict({("a%d" % i): v for i, v in enumerate(FALSE_RED_ALERT_IDS)},
res=VOID_RESOLUTION))
print("\n=== 已提交:%d 项阈值/单位%s ===" % (len(todo),
";已作废假红灯 %s" % (FALSE_RED_ALERT_IDS,) if args.resolve_false_reds else ""))
print("-- 写入后回查 --")
for x in fetch_plan_rows():
if x["kpi_code"] in PLAN:
print(" id=%-4s %-6s unit=%s 阈值=%s|%s|%s"
% (x["id"], x["kpi_code"], x["unit"], x["threshold_green"],
x["threshold_yellow"], x["threshold_red"]))
return 0
if __name__ == "__main__":
sys.exit(main())