Files
cma-management/backend/scripts/fix_threshold_data_20260911.py
T
Hermes CI Fix 4c6fb85cf5 fix(kpi): 阈值红黄绿读写/填写规则修复 — 统一解析器+红灯优先+建议可解析+前端校验+存量修正 (project-20260911-threshold-fix)
① 新建 app/utils/threshold_parser.py 统一解析/判定(单一事实源):
   - 支持负数(>=-100000)、闭区间 A~B、多条件 C1|C2;空串=未设置
   - 判定顺序 red→green→yellow:红灯优先(消除宽黄灯遮蔽红灯),
     green 先于 yellow(green 是主条件,yellow 是更宽兜底带)
② kpis.py / alert_generator.py 双实现收敛到统一解析器(消除重复维护)
③ thresholds.py suggest 输出可解析表达式(原黄色/适中型生成 `52.33~107.67`、`偏离55%` 等
   区间/文字 → 用户点"自动建议"填入后静默失效;另修复大数目标生成 1.6e+06 科学计数法)
④ KPIDetail.vue 三个阈值输入加 placeholder/tooltip/帮助文案 + 保存前格式校验(非法即拒)
⑤ 存量 4 个不可解析表达式修正(id=46 C_REBATE_RATE / id=327 BH_SALES_10),
   改前 mysqldump 备份 backend/scripts/backups/kpi_definitions.bak-20260911.sql +
   threshold_rows_before_20260911.json(可回滚);不可解析 5→0
⑥ 单测 97 条 + 红黄绿判定回归 367 判定点(旧→新差异 14 条,含差异说明)
2026-09-11 17:25:24 +08:00

159 lines
5.7 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 -*-
"""存量阈值数据修正(project-20260911-threshold-fix ⑤)
修正前:
1) 备份 kpi_definitions 全表 → backend/scripts/backups/kpi_definitions.bak-20260911.sql(mysqldump)
2) 记录 3 行的原值 → backups/threshold_rows_before_20260911.json(可回滚)
修正内容(7 个不可解析表达式 → 0):
id=32 F_EVA : Y='>=-100000' R='<-100000' → 解析器支持负数后自然可解析,无需改值(仅验证)
id=46 C_REBATE_RATE: G='52.33~107.67'(保留原绿区间)
Y='偏离55%' → '<52.33|>107.67'(绿区间外即黄)
R='偏离>55%' → '<=36|>=124'(80±27.67×2 ≈ 原"55"口径)
id=327 BH_SALES_10 : G=''(补) → '>=200'
Y='偏离182878%'→ '>=160'
R='偏离>182878%'→ '<120'
口径对齐同族 BH_SALES_1..9:G>=target / Y>=0.8target / R<0.6target
用法:
python3 scripts/fix_threshold_data_20260911.py # 预演(只打印)
python3 scripts/fix_threshold_data_20260911.py --apply # 备份 + 落库
"""
import os
import sys
import json
import subprocess
from datetime import datetime
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
BACKUP_DIR = os.path.join(BASE, "scripts", "backups")
envf = os.path.join(BASE, ".env")
ENV = {}
if os.path.exists(envf):
for line in open(envf, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
ENV[k.strip()] = v.strip().strip('"').strip("'")
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
from app.database import get_session_local
from app.models import KPIDefinition
from app.utils.threshold_parser import is_valid_threshold
# 目标行号 → 新值(None 表示不改动)
FIXES = {
46: {"threshold_green": "52.33~107.67",
"threshold_yellow": "<52.33|>107.67",
"threshold_red": "<=36|>=124"},
327: {"threshold_green": ">=200",
"threshold_yellow": ">=160",
"threshold_red": "<120"},
}
# 仅验证不修改
VERIFY_ONLY = [32]
FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
def scan(db):
"""返回 (总表达式数, 不可解析明细)"""
rows = db.query(KPIDefinition).all()
total = 0
bad = []
for k in rows:
for f in FIELDS:
e = getattr(k, f)
if e is None or not str(e).strip():
continue
total += 1
if not is_valid_threshold(e):
bad.append((k.id, k.kpi_code, f, str(e)))
return total, bad
def dump_table_backup():
os.makedirs(BACKUP_DIR, exist_ok=True)
out = os.path.join(BACKUP_DIR, "kpi_definitions.bak-20260911.sql")
cmd = ["mysqldump",
f"-u{ENV.get('CMA_DB_USER', 'cma_user')}",
f"-p{ENV.get('CMA_DB_PASS', '')}",
f"-h{ENV.get('CMA_DB_HOST', '127.0.0.1')}",
f"-P{ENV.get('CMA_DB_PORT', '3306')}",
ENV.get("CMA_DB_NAME", "cma"), "kpi_definitions"]
with open(out, "w", encoding="utf-8") as fh:
p = subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE)
if p.returncode != 0:
raise SystemExit("mysqldump 失败: " + p.stderr.decode("utf-8", "ignore")[:400])
return out
def main():
apply = "--apply" in sys.argv
db = get_session_local()()
total_before, bad_before = scan(db)
print(f"[before] 非空阈值表达式 {total_before} 条, 不可解析 {len(bad_before)} 条")
for b in bad_before:
print(" BAD", b)
# 记录原值(备份 json)
rows_before = []
for tid in list(FIXES.keys()) + VERIFY_ONLY:
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if k:
rows_before.append({"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"target_value": k.target_value,
**{f: getattr(k, f) for f in FIELDS}})
os.makedirs(BACKUP_DIR, exist_ok=True)
jpath = os.path.join(BACKUP_DIR, "threshold_rows_before_20260911.json")
if apply:
with open(jpath, "w", encoding="utf-8") as fh:
json.dump({"created_at": datetime.now().isoformat(), "rows": rows_before},
fh, ensure_ascii=False, indent=2)
sql_path = dump_table_backup()
print(f"[backup] json={jpath}")
print(f"[backup] sql={sql_path}")
else:
print(f"[dry-run] 将写入备份 json={jpath}(未执行)")
for r in rows_before:
print(" before:", r)
# 应用修正
for tid, newvals in FIXES.items():
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if not k:
print(f" !! id={tid} 不存在,跳过")
continue
for f, v in newvals.items():
old = getattr(k, f)
print(f" id={tid} {f}: {old!r} -> {v!r}")
if apply:
setattr(k, f, v)
if apply:
db.commit()
# 回查
for tid in list(FIXES.keys()) + VERIFY_ONLY:
k = db.query(KPIDefinition).filter(KPIDefinition.id == tid).first()
if k:
vals = {f: getattr(k, f) for f in FIELDS}
ok = all(is_valid_threshold(v) for v in vals.values())
print(f"[after] id={tid} {k.kpi_code} {vals} parseable={ok}")
total_after, bad_after = scan(db)
print(f"[after] 非空阈值表达式 {total_after} 条, 不可解析 {len(bad_after)} 条")
for b in bad_after:
print(" BAD", b)
db.close()
if not apply:
print("※ 预演模式,未落库。加 --apply 执行。")
if __name__ == "__main__":
main()