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 条,含差异说明)
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user