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
227 lines
12 KiB
Python
227 lines
12 KiB
Python
#!/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())
|