① 新建 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 条,含差异说明)
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
#!/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()
|