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:
Hermes CI Fix
2026-09-11 17:25:24 +08:00
parent f61b6ce604
commit 4c6fb85cf5
11 changed files with 1981 additions and 122 deletions
+27 -20
View File
@@ -5,13 +5,17 @@ from sqlalchemy import func
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
import json import json
import re
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import (
calc_level as _tp_calc_level,
is_valid_threshold,
THRESHOLD_HELP,
)
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"], router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -113,25 +117,11 @@ REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RAT
def _static_threshold_level(value, kpi) -> str: def _static_threshold_level(value, kpi) -> str:
"""按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 info。 """按 KPI 静态阈值文本判定级别(统一解析器,判定顺序 red→green→yellow),无阈值返回 info。
供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致。"""
for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"), 供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致:
("red", "threshold_red")): 解析与判定逻辑统一收敛到 app.utils.threshold_parser(单一事实源,2026-09-11 修复双实现+红灯遮蔽)。"""
expr = getattr(kpi, field, None) return _tp_calc_level(value, kpi)
if not expr:
continue
m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
if not m:
continue
op, val = m.group(1), float(m.group(2))
hit = {
">=": value >= val, "<=": value <= val,
">": value > val, "<": value < val,
"=": value == val, "!=": value != val,
}[op]
if hit:
return level
return "info"
def _calc_five_tier_score(current_value, target_value, is_reverse=False): def _calc_five_tier_score(current_value, target_value, is_reverse=False):
@@ -749,6 +739,21 @@ def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
return data return data
THRESHOLD_FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
def validate_threshold_format(data: dict) -> List[str]:
"""阈值表达式格式校验(2026-09-11):非法格式返回错误文本,让保存被拒而不是静默失效。
与 app.utils.threshold_parser 共用同一套语法(支持负数/区间 A~B/多条件 |)。
"""
errs: List[str] = []
for f in THRESHOLD_FIELDS:
if f in data and not is_valid_threshold(data.get(f)):
errs.append(f"阈值格式非法 [{f}={data.get(f)}]:{THRESHOLD_HELP}")
return errs
@router.post("") @router.post("")
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
# 检查编码唯一性 # 检查编码唯一性
@@ -757,6 +762,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在") raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
# 数据治理校验(规则1强制拦截) # 数据治理校验(规则1强制拦截)
errs = _validate_kpi_data(data, db=db, is_update=False) errs = _validate_kpi_data(data, db=db, is_update=False)
errs = errs + validate_threshold_format(data)
if errs: if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a) data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
@@ -799,6 +805,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
# 数据治理校验(更新时只检查传了但为空的字段) # 数据治理校验(更新时只检查传了但为空的字段)
errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True) errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
errs = errs + validate_threshold_format(data)
if errs: if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data.pop("entity_id", None) # 禁止通过update改企业归属 data.pop("entity_id", None) # 禁止通过update改企业归属
+115 -57
View File
@@ -1,9 +1,17 @@
"""阈值智能推荐 API""" """阈值智能推荐 API
2026-09-11 (project-20260911-threshold-fix) 修复:
推荐值必须与后端解析器(app.utils.threshold_parser)语法一致 —— 原实现给黄灯/适中型
生成区间/文字标签(`52.33~107.67`、`偏离55%`),用户点“自动建议”填入后保存即静默失效。
现在统一生成可解析表达式:标量比较(支持负数)/ 闭区间 `A~B` / 多条件 `C1|C2`。
"""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, OperationLog from app.models import KPIDefinition, KPIValue, OperationLog
from app.utils.threshold_parser import THRESHOLD_HELP, is_valid_threshold
from typing import Any, Optional
import json import json
router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"], router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"],
@@ -18,9 +26,97 @@ KPI_TYPES = {
"middle_best": ["TOP5_CUSTOMER_RATIO"], "middle_best": ["TOP5_CUSTOMER_RATIO"],
} }
def _r(v) -> float:
"""保留2位小数(避免浮点噪声写进表达式)"""
return round(float(v), 2)
def _fmt(v) -> str:
"""数值 → 表达式文本。禁用科学计数法:`:g` 在 |v|>=1e6 时会输出 1.6e+06,
解析器不认(会导致推荐值再次静默失效)。"""
s = f"{float(v):.2f}".rstrip("0").rstrip(".")
return s if s not in ("", "-") else "0"
def build_suggestion(kpi_type: str, target: float, tolerance: Optional[float] = None) -> dict:
"""按类型生成可解析的红黄绿阈值建议(仅生成表达式,不含 I/O)。
口径(label = 该等级的触发条件,判定优先级 red→green→yellow):
higher_better:绿 `>=0.8T`、黄 `>=0.5T`(更宽的兜底带)、红 `<0.5T`
lower_better :绿 `<=1.2T`、黄 `<=2.0T`、红 `>2.0T`
middle_best :绿 `T±tol` 闭区间、黄 `T±2tol` 外环带(green ⊂ yellow)、红 `<T-2tol|>T+2tol`
"""
if kpi_type == "higher_better":
green_min = _r(target * 0.8)
yellow_min = _r(target * 0.5)
red_max = yellow_min
return {
"type": "higher_better",
"description": "越高越好型",
"green": {"min": green_min, "max": None, "label": f">={_fmt(green_min)}"},
"yellow": {"min": yellow_min, "max": green_min, "label": f">={_fmt(yellow_min)}"},
"red": {"min": None, "max": red_max, "label": f"<{_fmt(red_max)}"},
}
if kpi_type == "lower_better":
green_max = _r(target * 1.2)
yellow_max = _r(target * 2.0)
red_min = yellow_max
return {
"type": "lower_better",
"description": "越低越好型",
"green": {"min": None, "max": green_max, "label": f"<={_fmt(green_max)}"},
"yellow": {"min": green_max, "max": yellow_max, "label": f"<={_fmt(yellow_max)}"},
"red": {"min": red_min, "max": None, "label": f">{_fmt(red_min)}"},
}
# middle_best(适中最好型):绿=内区间 T±tol,黄=外环带 T±2tol(green ⊂ yellow),红=环带之外
tol = float(tolerance) if tolerance else abs(float(target)) * 0.2
if tol <= 0:
tol = 1.0
a, b = _r(target - tol), _r(target + tol)
c, d = _r(target - 2 * tol), _r(target + 2 * tol)
return {
"type": "middle_best",
"description": "适中最好型",
"green": {"min": a, "max": b, "label": f"{_fmt(a)}~{_fmt(b)}"},
"yellow": {"min": c, "max": d, "label": f"{_fmt(c)}~{_fmt(d)}"},
"red": {"min": None, "max": None, "label": f"<{_fmt(c)}|>{_fmt(d)}"},
}
def suggest_by_type(kpi):
"""无历史数据时按类型推荐(同样输出可解析表达式,含 yellow/red 标签)"""
target = kpi.target_value or 100
kpi_type = _kpi_type(kpi)
suggestion = build_suggestion(kpi_type, target)
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
def _kpi_type(kpi) -> str:
if kpi.kpi_code in KPI_TYPES["lower_better"]:
return "lower_better"
if kpi.kpi_code in KPI_TYPES["middle_best"]:
return "middle_best"
return "higher_better"
def _sanity_check(suggestion: dict) -> dict:
"""自检:推荐值必须过解析器(防再次生成不可解析格式),并挂 format 提示给前端。"""
bad = []
for level in ("green", "yellow", "red"):
label = (suggestion.get(level) or {}).get("label")
if label and not is_valid_threshold(label):
bad.append({"level": level, "label": label})
suggestion["parseable"] = not bad
if bad:
suggestion["unparseable"] = bad
return suggestion
@router.get("/suggest/{kpi_id}") @router.get("/suggest/{kpi_id}")
def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)): def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
"""根据历史数据自动推荐阈值""" """根据历史数据自动推荐阈值(输出格式保证被 threshold_parser 解析生效)"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi: if not kpi:
return {"error": "KPI不存在"} return {"error": "KPI不存在"}
@@ -36,62 +132,24 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
if len(nums) < 2: if len(nums) < 2:
return suggest_by_type(kpi) return suggest_by_type(kpi)
avg = sum(nums) / len(nums) # 注: SQLAlchemy 模型属性未做 Mapped 注解, Pyright 会推断为 Column;
# 这里统一标注 Any 抑制静态噪音(运行期均为 float)。
avg: Any = sum(nums) / len(nums)
# 计算标准差 # 计算标准差
variance = sum((x - avg) ** 2 for x in nums) / len(nums) variance: Any = sum((x - avg) ** 2 for x in nums) / len(nums)
std = variance ** 0.5 std: Any = variance ** 0.5
target = kpi.target_value or avg target: Any = kpi.target_value or avg
kpi_type = _kpi_type(kpi)
# 根据KPI类型生成推荐区间
if kpi.kpi_code in KPI_TYPES["higher_better"]:
green_min = round(target * 0.8, 2)
yellow_min = round(target * 0.5, 2)
red_max = round(target * 0.5, 2)
suggestion = {
"type": "higher_better",
"description": "越高越好型",
"green": {"min": green_min, "max": None, "label": f">={green_min}"},
"yellow": {"min": yellow_min, "max": green_min, "label": f"{yellow_min}~{green_min}"},
"red": {"min": None, "max": red_max, "label": f"<{red_max}"},
"current_avg": round(avg, 2),
"target": target,
}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
green_max = round(target * 1.2, 2)
yellow_max = round(target * 2.0, 2)
suggestion = {
"type": "lower_better",
"description": "越低越好型",
"green": {"min": None, "max": green_max, "label": f"<={green_max}"},
"yellow": {"min": green_max, "max": yellow_max, "label": f"{green_max}~{yellow_max}"},
"red": {"min": yellow_max, "max": None, "label": f">{yellow_max}"},
"current_avg": round(avg, 2),
"target": target,
}
else:
tolerance = max(std * 1.5, target * 0.2)
suggestion = {
"type": "middle_best",
"description": "适中最好型",
"green": {"min": round(target - tolerance, 2), "max": round(target + tolerance, 2), "label": f"{round(target-tolerance,2)}~{round(target+tolerance,2)}"},
"yellow": {"min": round(target - tolerance*2, 2), "max": round(target + tolerance*2, 2), "label": f"偏离{(tolerance*2):.0f}%"},
"red": {"min": None, "max": round(target - tolerance*2, 2), "label": f"偏离>{tolerance*2:.0f}%"},
"current_avg": round(avg, 2),
"target": target,
}
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name, "suggestion": suggestion}
def suggest_by_type(kpi): if kpi_type == "middle_best":
"""无历史数据时按类型推荐""" tolerance: Any = max(std * 1.5, target * 0.2)
target = kpi.target_value or 100 suggestion = build_suggestion(kpi_type, target, tolerance)
if kpi.kpi_code in KPI_TYPES["higher_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "higher_better", "green": {"min": round(target*0.8,2)}, "yellow": {"min": round(target*0.5,2)}, "red": {"max": round(target*0.5,2)}}}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "lower_better", "green": {"max": round(target*1.2,2)}, "yellow": {"max": round(target*2,2)}, "red": {"min": round(target*2,2)}}}
else: else:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据", suggestion = build_suggestion(kpi_type, target)
"suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}} suggestion = _sanity_check(suggestion)
suggestion["current_avg"] = round(avg, 2)
suggestion["target"] = target
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name,
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""阈值表达式统一解析与红黄绿判定 —— 单一事实源(Single Source of Truth)
背景(project-20260911-threshold-fix):
原先 kpis.py 与 alert_generator.py 各自实现一份阈值解析(正则相同、独立维护),
且解析器不支持负数,导致 `>=-100000` 这类表达式静默失效。
表达式语法(label 的语义 = “该等级的触发条件”,不是区间标签):
>=N <=N >N <N =N !=N 标量比较,N 支持负数与小数(如 `<-100000`、`>=52.33`)
A~B 闭区间包含,A <= 实际值 <= B(如 `52.33~107.67`)
C1|C2 多条件或,任一命中即为命中(如 `<=36|>=124`)
允许空白:`>= 800`、`52.33 ~ 107.67`;空串/None 视为“无阈值”。
红黄绿判定优先级:red → green → yellow(理由见下方 calc_level_from_exprs)
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple, Union
# 标量比较:操作符 + 数值(负号/小数)
_SCALAR_RE = re.compile(r"(>=|<=|!=|>|<|=)\s*(-?\d+(?:\.\d+)?)")
# 区间:A~B(闭区间)
_RANGE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*~\s*(-?\d+(?:\.\d+)?)")
# 多条件分隔(半角竖线 / 全角竖线)
_CLAUSE_SPLIT_RE = re.compile(r"\s*[||]\s*")
#: 单个子句取值:标量 -> float;区间 -> (low, high)
ThresholdValue = Union[float, Tuple[float, float]]
Clause = Tuple[str, ThresholdValue]
#: 支持的格式说明(前端 tooltip / 接口文档共用文案)
THRESHOLD_HELP = (
"支持格式:>=800 / <=25 / >60 / <20 / =100 / !=0(数值可为负数,如 <-100000);"
"区间 52.33~107.67(含端点);多条件用 | 连接,如 <=36|>=124。"
)
THRESHOLD_PLACEHOLDER = "如 >=800 / 52.33~107.67"
#: 判定优先级(严重度最高者优先;green 优先于 yellow 见 docstring 说明)
LEVEL_PRIORITY: Tuple[str, str, str] = ("red", "green", "yellow")
def _is_blank(expr) -> bool:
return expr is None or not str(expr).strip()
def parse_clause(clause) -> Optional[Clause]:
"""解析单个子句;非法返回 None。"""
s = str(clause).strip()
if not s:
return None
m = _RANGE_RE.fullmatch(s)
if m:
lo, hi = float(m.group(1)), float(m.group(2))
return ("~", (lo, hi))
m = _SCALAR_RE.fullmatch(s)
if m:
return (m.group(1), float(m.group(2)))
return None
def parse_clauses(expr) -> List[Clause]:
"""解析表达式为子句列表;任一子句非法则整体视为非法(返回空列表)。"""
if _is_blank(expr):
return []
parts = _CLAUSE_SPLIT_RE.split(str(expr).strip())
clauses: List[Clause] = []
for p in parts:
c = parse_clause(p)
if c is None:
return []
clauses.append(c)
return clauses
def parse_threshold(expr) -> Tuple[Optional[str], Optional[ThresholdValue]]:
"""解析阈值表达式,返回 (operator, value)。
保持向后兼容的二元返回:解析失败 -> (None, None)。
区间操作符为 "~",value 为 (low, high);多条件表达式返回首个命中子句之外的首个子句。
示例:
'>=32000000' -> ('>=', 32000000.0)
'>=-100000' -> ('>=', -100000.0)
'52.33~107.67' -> ('~', (52.33, 107.67))
"""
clauses = parse_clauses(expr)
if not clauses:
return None, None
return clauses[0]
def is_valid_threshold(expr) -> bool:
"""表达式是否合法(空串视为合法=不设置阈值)。"""
if _is_blank(expr):
return True
return bool(parse_clauses(expr))
def eval_clause(actual, clause: Clause) -> bool:
op, val = clause
if actual is None:
return False
if op == ">=":
return actual >= val # type: ignore[operator]
if op == "<=":
return actual <= val # type: ignore[operator]
if op == ">":
return actual > val # type: ignore[operator]
if op == "<":
return actual < val # type: ignore[operator]
if op == "=":
return actual == val
if op == "!=":
return actual != val
if op == "~":
lo, hi = val # type: ignore[misc]
return lo <= actual <= hi
return False
def eval_threshold(actual, expr) -> bool:
"""实际值是否命中阈值表达式(多条件任一命中即 True;非法表达式 -> False)。"""
if actual is None or _is_blank(expr):
return False
clauses = parse_clauses(expr)
return any(eval_clause(actual, c) for c in clauses)
def calc_level_from_exprs(actual, green=None, yellow=None, red=None) -> str:
"""红黄绿判定(统一入口),返回 'red' / 'yellow' / 'green' / 'info'。
判定顺序 red → green → yellow:
1. red 最严重,必须最先判 —— 否则宽黄灯(如 Y='>76')会遮蔽红灯(如 R='>91'),
红灯永远不可达(缺陷5:id=6 实测值 95 被判黄)。
2. green 先于 yellow —— green 表达式(如 G='>=800')是“主条件”,
yellow(如 Y='>=700')是更宽的兜底带;若 yellow 先判,值 900 会被黄灯截胡,
与用户“>=800 即绿”的填写意图相反(id=1 实测)。
对 moderate 型(绿=内区间、黄=外侧环带)两者天然互斥,顺序无影响。
3. 三者均不命中 -> 'info'(无判定,与旧实现一致)。
"""
if actual is None:
return "info"
exprs = {"green": green, "yellow": yellow, "red": red}
for level in LEVEL_PRIORITY:
if eval_threshold(actual, exprs[level]):
return level
return "info"
def calc_level(actual, kpi) -> str:
"""按 KPI 定义对象(含 threshold_green/yellow/red 属性)判定级别。"""
return calc_level_from_exprs(
actual,
green=getattr(kpi, "threshold_green", None),
yellow=getattr(kpi, "threshold_yellow", None),
red=getattr(kpi, "threshold_red", None),
)
def describe_level(actual, kpi) -> Tuple[str, Optional[str]]:
"""返回 (级别, 命中的表达式)。供预警消息构造使用;无命中 -> (level, None)。"""
level = calc_level(actual, kpi)
if level == "info":
return level, None
field = {"green": "threshold_green", "yellow": "threshold_yellow", "red": "threshold_red"}[level]
return level, getattr(kpi, field, None)
+16 -37
View File
@@ -6,7 +6,6 @@
import sys import sys
import os import os
import re
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -15,22 +14,15 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
logger = logging.getLogger("cma.alert_gen") logger = logging.getLogger("cma.alert_gen")
def _parse_threshold(expr: str) -> tuple: def _parse_threshold(expr: str) -> tuple:
"""解析阈值表达式,返回 (operator, value) """[兼容保留] 统一委托 app.utils.threshold_parser.parse_threshold"""
示例: from app.utils.threshold_parser import parse_threshold as _p
>=32000000 -> ('>=', 32000000) return _p(expr)
<20 -> ('<', 20)
<=55 -> ('<=', 55)
>60 -> ('>', 60)
"""
m = re.match(r"(>=|<=|>|<|=|!=)\s*([\d.]+)", str(expr).strip())
if m:
return m.group(1), float(m.group(2))
return None, None
def cleanup_green_alerts(db_session) -> int: def cleanup_green_alerts(db_session) -> int:
@@ -52,33 +44,16 @@ def cleanup_green_alerts(db_session) -> int:
def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple: def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
"""检查实际值是否触发阈值,返回 (触发, 消息)""" """检查实际值是否触发阈值,返回 (触发, 消息)(统一走 app.utils.threshold_parser)"""
if not threshold_expr or actual is None: if not threshold_expr or actual is None:
return False, "" return False, ""
op, val = _parse_threshold(threshold_expr) if not eval_threshold(actual, threshold_expr):
if op is None:
return False, "" return False, ""
triggered = False msg = (f"KPI当前值 {actual:.2f},触发{ {'green': '正常', 'yellow': '预警', 'red': '紧急'}.get(level, level) }阈值 "
if op == ">=" and actual >= val: f"({threshold_expr})")
triggered = True return True, msg
elif op == "<=" and actual <= val:
triggered = True
elif op == ">" and actual > val:
triggered = True
elif op == "<" and actual < val:
triggered = True
elif op == "=" and actual == val:
triggered = True
if triggered:
level_names = {"green": "正常", "yellow": "预警", "red": "紧急"}
msg = (f"KPI当前值 {actual:.2f},触发{level_names.get(level, level)}阈值 "
f"({threshold_expr})")
return True, msg
return False, ""
def run_alert_check(db_session, period: str = None) -> int: def run_alert_check(db_session, period: str = None) -> int:
@@ -112,9 +87,12 @@ def run_alert_check(db_session, period: str = None) -> int:
actual = latest.actual_value actual = latest.actual_value
# 从绿到红检查(绿灯最高优先级——满足即止) # 判定顺序:红灯优先 → 绿灯 → 黄灯(最严重者优先,消除宽黄灯遮蔽红灯)
# 2026-09-11 修复「黄灯遮蔽红灯」:原顺序 绿→黄→红命中即止,宽黄灯(如 Y='>76')
# 会让红灯(如 R='>91')永远不可达(id=6 实测值 95 被判黄);
# green 先于 yellow:green 是主条件,yellow 是更宽的兜底带(id=1 G='>=800' Y='>=700')。
# Bug1修复: green(正常)不生成pending预警,只对yellow/red落库 # Bug1修复: green(正常)不生成pending预警,只对yellow/red落库
for level in ["green", "yellow", "red"]: for level in LEVEL_PRIORITY:
expr = thr[level] expr = thr[level]
if not expr: if not expr:
continue continue
@@ -173,7 +151,8 @@ def run_alert_check(db_session, period: str = None) -> int:
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}") logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别 break # 只取最高级别
elif level == "red" and not triggered: elif level == "red" and not triggered:
# 红没触发,如果已有红色预警但当前不满足,自动降级或关闭 # 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
# 说明当前值已不再触发红色阈值,自动解除(保留原有自动降级行为)
existing_red = db_session.query(KPIAlert).filter( existing_red = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id, KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == latest.id, KPIAlert.kpi_value_id == latest.id,
@@ -0,0 +1,751 @@
{
"samples": 367,
"count_old": {
"red": 120,
"yellow": 17,
"green": 46,
"info": 184
},
"count_new": {
"red": 126,
"yellow": 12,
"green": 54,
"info": 175
},
"diff_ab": [
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-04",
"actual": 152.62,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-05",
"actual": 105.47,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-07",
"actual": 116.5,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 6,
"kpi_code": "F_AR_DAYS",
"period": "2026-08",
"actual": 610.2,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=76",
"Y": ">76",
"R": ">91"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-01",
"actual": 124.1,
"old": "info",
"new": "red",
"severity_order": "red",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-02",
"actual": 84.0,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-03",
"actual": 69.6,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-04",
"actual": 81.7,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-05",
"actual": 88.3,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-06",
"actual": 52.8,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-07",
"actual": 68.7,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-08",
"actual": 75.0,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 46,
"kpi_code": "C_REBATE_RATE",
"period": "2026-H1",
"actual": 86.4,
"old": "info",
"new": "green",
"severity_order": "green",
"G": "52.33~107.67",
"Y": "<52.33|>107.67",
"R": "<=36|>=124"
},
{
"kpi_id": 407,
"kpi_code": "F_REBATE_RATE",
"period": "2026-08",
"actual": 48.44,
"old": "yellow",
"new": "red",
"severity_order": "red",
"G": "<=10",
"Y": ">10",
"R": ">12"
}
],
"diff_bc": [
{
"kpi_id": 3,
"kpi_code": "F_NET_PROFIT",
"period": "2026-01",
"actual": 94.31,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=30",
"Y": ">=20",
"R": "<10"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-01",
"actual": 65.2173,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-02",
"actual": 78.4307,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-04",
"actual": 124.422,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 4,
"kpi_code": "F_OP_CFLOW",
"period": "2026-06",
"actual": 43.4998,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=0",
"R": "<0"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-01",
"actual": 15.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-02",
"actual": 18.85,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-06",
"actual": 24.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 5,
"kpi_code": "F_COST_RATIO",
"period": "2026-H1",
"actual": 24.9,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<=25",
"Y": "<=30",
"R": ">30"
},
{
"kpi_id": 8,
"kpi_code": "C_NEW_CLIENTS",
"period": "2026-07",
"actual": 88.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=10",
"Y": ">=5",
"R": "<5"
},
{
"kpi_id": 14,
"kpi_code": "F_REVENUE_GROWTH",
"period": "2026-05",
"actual": 48.91,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=15",
"R": "<15"
},
{
"kpi_id": 14,
"kpi_code": "F_REVENUE_GROWTH",
"period": "2026-06",
"actual": 43.54,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=20",
"Y": ">=15",
"R": "<15"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-01",
"actual": 37.01,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-02",
"actual": 38.33,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-04",
"actual": 42.02,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-05",
"actual": 41.49,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-06",
"actual": 39.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-07",
"actual": 38.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 30,
"kpi_code": "F_DEBT_RATIO",
"period": "2026-08",
"actual": 39.9,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": "<50",
"Y": "<70",
"R": ">=70"
},
{
"kpi_id": 184,
"kpi_code": "FB_ANALYSIS_COUNT",
"period": "2026-07",
"actual": 18.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=12",
"Y": ">=8",
"R": "<4"
},
{
"kpi_id": 318,
"kpi_code": "BH_SALES_1",
"period": "2026-H1",
"actual": 754562.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=754562",
"Y": ">=603649",
"R": "<452737"
},
{
"kpi_id": 319,
"kpi_code": "BH_SALES_2",
"period": "2026-H1",
"actual": 579205.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=579205",
"Y": ">=463364",
"R": "<347523"
},
{
"kpi_id": 320,
"kpi_code": "BH_SALES_3",
"period": "2026-H1",
"actual": 209038.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=209038",
"Y": ">=167230",
"R": "<125422"
},
{
"kpi_id": 321,
"kpi_code": "BH_SALES_4",
"period": "2026-H1",
"actual": 285990.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=285990",
"Y": ">=228792",
"R": "<171594"
},
{
"kpi_id": 322,
"kpi_code": "BH_SALES_5",
"period": "2026-H1",
"actual": 372816.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=372816",
"Y": ">=298253",
"R": "<223690"
},
{
"kpi_id": 323,
"kpi_code": "BH_SALES_6",
"period": "2026-H1",
"actual": 229512.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=229512",
"Y": ">=183610",
"R": "<137707"
},
{
"kpi_id": 324,
"kpi_code": "BH_SALES_7",
"period": "2026-H1",
"actual": 35173.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=35173",
"Y": ">=28138",
"R": "<21104"
},
{
"kpi_id": 325,
"kpi_code": "BH_SALES_8",
"period": "2026-H1",
"actual": 2327.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=2327",
"Y": ">=1862",
"R": "<1396"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-01",
"actual": 625983.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-02",
"actual": 253099.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-03",
"actual": 526357.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 326,
"kpi_code": "BH_SALES_9",
"period": "2026-H1",
"actual": 1785.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=1785",
"Y": ">=1428",
"R": "<1071"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-02",
"actual": 1486.73,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-03",
"actual": 2991.15,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 327,
"kpi_code": "BH_SALES_10",
"period": "2026-H1",
"actual": 4180.0,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=200",
"Y": ">=160",
"R": "<120"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2022",
"actual": 103.4,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2025",
"actual": 107.6,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-01",
"actual": 201.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-04",
"actual": 120.5,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-05",
"actual": 110.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-08",
"actual": 278.7,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
},
{
"kpi_id": 408,
"kpi_code": "F_FACTORY_REBATE_RATE",
"period": "2026-H1",
"actual": 106.8,
"old": "green",
"new": "green",
"severity_order": "yellow",
"G": ">=100",
"Y": ">=80",
"R": "<80"
}
],
"alert_only_old": [
[
6,
"2026-04",
"yellow"
],
[
6,
"2026-05",
"yellow"
],
[
6,
"2026-07",
"yellow"
],
[
6,
"2026-08",
"yellow"
],
[
407,
"2026-08",
"yellow"
]
],
"alert_only_new": [
[
6,
"2026-04",
"red"
],
[
6,
"2026-05",
"red"
],
[
6,
"2026-07",
"red"
],
[
6,
"2026-08",
"red"
],
[
46,
"2026-01",
"red"
],
[
407,
"2026-08",
"red"
]
],
"alert_gen_dry_run_new": 0
}
@@ -0,0 +1,32 @@
{
"created_at": "2026-09-11T17:11:24.159913",
"rows": [
{
"id": 46,
"kpi_code": "C_REBATE_RATE",
"kpi_name": "渠补率",
"target_value": 80.0,
"threshold_green": "52.33~107.67",
"threshold_yellow": "偏离55%",
"threshold_red": "偏离>55%"
},
{
"id": 327,
"kpi_code": "BH_SALES_10",
"kpi_name": "销售-任富海",
"target_value": 200.0,
"threshold_green": "",
"threshold_yellow": "偏离182878%",
"threshold_red": "偏离>182878%"
},
{
"id": 32,
"kpi_code": "F_EVA",
"kpi_name": "经济增加值(EVA)",
"target_value": 0.0,
"threshold_green": ">=0",
"threshold_yellow": ">=-100000",
"threshold_red": "<-100000"
}
]
}
@@ -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()
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""阈值判定变更回归(project-20260911-threshold-fix ⑥)
对比三条链路的判定结果,产出「改前改后差异样本」:
A. 旧实现:alert_generator 顺序 绿→黄→红 + 正则 (>=|<=|>|<|=|!=)\\s*([\\d.]+)(不支持负数/区间)
B. 新实现 red→green→yellow(统一 threshold_parser)
C. 若按任务字面「red→yellow→green」严重度顺序(用于说明为何 green 必须先于 yellow)
输出:逐 (KPI, 期间最新值) 的 A/B 差异清单 + 预警集合差异 + 真实 alert_generator 事务内预演(不落库)
"""
import os
import re
import sys
import json
from collections import Counter
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
envf = os.path.join(BASE, ".env")
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)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue
from app.utils.threshold_parser import calc_level_from_exprs
OLD_RE = re.compile(r"(>=|<=|>|<|=|!=)\s*([\d.]+)")
def old_level(actual, kpi) -> str:
"""旧实现:绿→黄→红,命中即止;不支持负数/区间。"""
if actual is None:
return "info"
for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"),
("red", "threshold_red")):
expr = getattr(kpi, field, None)
if not expr:
continue
m = OLD_RE.match(str(expr).strip())
if not m:
continue
op, val = m.group(1), float(m.group(2))
hit = {">=": actual >= val, "<=": actual <= val, ">": actual > val,
"<": actual < val, "=": actual == val, "!=": actual != val}[op]
if hit:
return level
return "info"
def severity_order_level(actual, kpi) -> str:
"""按任务字面顺序 red→yellow→green(严重度优先)——用于对比说明。"""
for level, field in (("red", "threshold_red"), ("yellow", "threshold_yellow"),
("green", "threshold_green")):
expr = getattr(kpi, field, None)
if not expr:
continue
from app.utils.threshold_parser import eval_threshold
if eval_threshold(actual, expr):
return level
return "info"
def main():
db = get_session_local()()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
samples = []
diffs_ab = []
diffs_bc = []
count_old = Counter()
count_new = Counter()
for kpi in kpis:
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).all()
# 每期间取最新一条(对齐 alert_generator 取数方式)
latest_by_period = {}
for v in vals:
if v.actual_value is None:
continue
key = v.period
cur = latest_by_period.get(key)
if cur is None or (v.calculated_at and cur.calculated_at and v.calculated_at > cur.calculated_at):
latest_by_period[key] = v
for period, v in sorted(latest_by_period.items()):
a = old_level(v.actual_value, kpi)
b = calc_level_from_exprs(v.actual_value, kpi.threshold_green,
kpi.threshold_yellow, kpi.threshold_red)
c = severity_order_level(v.actual_value, kpi)
samples.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "period": period,
"actual": v.actual_value, "old": a, "new": b, "severity_order": c,
"G": kpi.threshold_green, "Y": kpi.threshold_yellow, "R": kpi.threshold_red,
})
count_old[a] += 1
count_new[b] += 1
if a != b:
diffs_ab.append(samples[-1])
if c != b:
diffs_bc.append(samples[-1])
print(f"参与回归的 (KPI, 期间) 判定点: {len(samples)}")
print("旧实现级别分布 :", dict(count_old))
print("新实现级别分布 :", dict(count_new))
print(f"\n=== A(旧) vs B(新) 差异 {len(diffs_ab)} 条 ===")
for d in diffs_ab:
print(f" kpi={d['kpi_code']}(id={d['kpi_id']}) {d['period']} actual={d['actual']} "
f"old={d['old']} -> new={d['new']} G={d['G']!r} Y={d['Y']!r} R={d['R']!r}")
print(f"\n=== B(new=red>green>yellow) vs C(red>yellow>green) 差异 {len(diffs_bc)} 条 ===")
for d in diffs_bc[:20]:
print(f" kpi={d['kpi_code']}(id={d['kpi_id']}) {d['period']} actual={d['actual']} "
f"B={d['new']} vs C={d['severity_order']} G={d['G']!r} Y={d['Y']!r} R={d['R']!r}")
if len(diffs_bc) > 20:
print(f" ... 其余 {len(diffs_bc) - 20} 条")
# 预警集合差异(yellow/red 才落库)
old_alerts = [(d['kpi_id'], d['period'], d['old']) for d in samples if d['old'] in ("yellow", "red")]
new_alerts = [(d['kpi_id'], d['period'], d['new']) for d in samples if d['new'] in ("yellow", "red")]
print(f"\n=== 预警集合:旧 {len(old_alerts)} 条 → 新 {len(new_alerts)} 条 ===")
so, sn = set(old_alerts), set(new_alerts)
for x in sorted(so - sn):
print(" 仅旧有(解除):", x)
for x in sorted(sn - so):
print(" 仅新有(新增):", x)
# 真实 alert_generator 事务内预演(阻断 commit,事后 rollback,零落库)
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"cma_alert_generator", os.path.join(BASE, "scripts", "alert_generator.py"))
ag = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ag)
from datetime import datetime
period = datetime.now().strftime("%Y-%m")
real_commit = db.commit
db.commit = lambda: None # 阻断落库
try:
new_count = ag.run_alert_check(db, period)
finally:
db.commit = real_commit
db.rollback()
print(f"\n=== alert_generator 事务内预演(period={period},已 rollback 未落库)新增预警={new_count} ===")
out = os.path.join(BASE, "scripts", "backups", "threshold_regression_20260911.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"samples": len(samples), "count_old": dict(count_old), "count_new": dict(count_new),
"diff_ab": diffs_ab, "diff_bc": diffs_bc,
"alert_only_old": sorted(so - sn), "alert_only_new": sorted(sn - so),
"alert_gen_dry_run_new": new_count}, fh, ensure_ascii=False, indent=2)
print("报告 JSON:", out)
db.close()
if __name__ == "__main__":
main()
+237
View File
@@ -0,0 +1,237 @@
"""
统一阈值解析器 / 建议格式 / 红灯优先判定 测试
(project-20260911-threshold-fix,2026-09-11)
覆盖 4 类缺陷:
① suggest 生成格式必须可解析(原生成区间/文字 → 保存后静默失效)
② 前端/接口格式校验(非法表达式被拒)
③ 解析器支持负数(>=-100000)
⑤ 红灯优先(宽黄灯不得遮蔽红灯)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.utils.threshold_parser import (
LEVEL_PRIORITY,
calc_level,
calc_level_from_exprs,
eval_threshold,
is_valid_threshold,
parse_threshold,
)
from app.api.thresholds import build_suggestion
from app.api.kpis import validate_threshold_format
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
class TestParseThreshold:
"""解析器:操作符 / 负数 / 区间 / 多条件 / 非法格式"""
def test_scalar_ops(self):
assert parse_threshold(">=32000000") == (">=", 32000000.0)
assert parse_threshold("<20") == ("<", 20.0)
assert parse_threshold("<=55") == ("<=", 55.0)
assert parse_threshold(">60") == (">", 60.0)
assert parse_threshold("=100") == ("=", 100.0)
assert parse_threshold("!=0") == ("!=", 0.0)
def test_negative_numbers(self):
"""缺陷3:原正则 [\\d.]+ 不支持负号 → 现金流类阈值静默失效"""
assert parse_threshold(">=-100000") == (">=", -100000.0)
assert parse_threshold("<-100000") == ("<", -100000.0)
assert eval_threshold(-200000, "<-100000") is True
assert eval_threshold(-50000, "<-100000") is False
def test_whitespace_tolerated(self):
assert parse_threshold(" >= 800 ") == (">=", 800.0)
def test_range(self):
assert parse_threshold("52.33~107.67") == ("~", (52.33, 107.67))
assert eval_threshold(80, "52.33~107.67") is True
assert eval_threshold(52.33, "52.33~107.67") is True # 含端点
assert eval_threshold(120, "52.33~107.67") is False
def test_or_clauses(self):
assert eval_threshold(130, "<=36|>=124") is True
assert eval_threshold(20, "<=36|>=124") is True
assert eval_threshold(80, "<=36|>=124") is False
@pytest.mark.parametrize("bad", ["偏离55%", "偏离>55%", "80%", ">=abc", "52.33~", ">=", ">=800%"])
def test_invalid(self, bad):
assert is_valid_threshold(bad) is False
assert parse_threshold(bad) == (None, None)
assert eval_threshold(100, bad) is False
def test_blank_is_valid_means_unset(self):
assert is_valid_threshold("") is True
assert is_valid_threshold(None) is True
assert eval_threshold(100, "") is False
def test_partial_or_clause_is_invalid(self):
"""多条件中任一子句非法 → 整体非法(防半截表达式静默生效)"""
assert is_valid_threshold("<=36|abc") is False
class TestCalcLevel:
def test_red_priority_over_yellow(self):
"""缺陷5:id=6 实测 Y='>76' R='>91',值 95 原判黄(红灯不可达)→ 必须判红"""
assert calc_level_from_exprs(95, "<=76", ">76", ">91") == "red"
assert calc_level_from_exprs(152.62, "<=76", ">76", ">91") == "red"
def test_green_priority_over_yellow(self):
"""green 是主条件、yellow 是更宽兜底带(id=1 G='>=800' Y='>=700'):
值 900 同时满足两条,用户意图是绿,不能被黄截胡"""
assert calc_level_from_exprs(900, ">=800", ">=700", "<600") == "green"
assert calc_level_from_exprs(750, ">=800", ">=700", "<600") == "yellow"
assert calc_level_from_exprs(500, ">=800", ">=700", "<600") == "red"
def test_lower_better(self):
assert calc_level_from_exprs(20, "<=25", "<=30", ">30") == "green"
assert calc_level_from_exprs(28, "<=25", "<=30", ">30") == "yellow"
assert calc_level_from_exprs(35, "<=25", "<=30", ">30") == "red"
def test_negative_cashflow(self):
"""缺陷3 场景:F_EVA G='>=0' Y='>=-100000' R='<-100000'"""
assert calc_level_from_exprs(-200000, ">=0", ">=-100000", "<-100000") == "red"
assert calc_level_from_exprs(-50000, ">=0", ">=-100000", "<-100000") == "yellow"
assert calc_level_from_exprs(100, ">=0", ">=-100000", "<-100000") == "green"
def test_moderate_band(self):
"""适中型:绿=内区间、黄=环带、红=外区间"""
g, y, r = "72~88", "<72|>88", "<=64|>=96"
assert calc_level_from_exprs(80, g, y, r) == "green"
assert calc_level_from_exprs(70, g, y, r) == "yellow"
assert calc_level_from_exprs(98, g, y, r) == "red"
def test_priority_constant(self):
assert LEVEL_PRIORITY == ("red", "green", "yellow")
def test_no_threshold_is_info(self):
assert calc_level_from_exprs(100, None, None, None) == "info"
assert calc_level_from_exprs(None, ">=1", None, None) == "info"
def test_calc_level_with_kpi_object(self):
class K:
threshold_green = "<=76"
threshold_yellow = ">76"
threshold_red = ">91"
assert calc_level(95, K()) == "red"
assert calc_level(70, K()) == "green"
class TestSuggestFormat:
"""缺陷1:suggest 生成值必须全部可解析(点“自动建议”填入后不再静默失效)"""
@pytest.mark.parametrize("kpi_type,target", [
("higher_better", 1200.0), ("lower_better", 80.0),
("middle_best", 80.0),
])
def test_all_labels_parseable(self, kpi_type, target):
s = build_suggestion(kpi_type, target, tolerance=10.0)
for level in ("green", "yellow", "red"):
label = s[level]["label"]
assert label, f"{kpi_type}.{level} 缺 label"
assert is_valid_threshold(label), f"{kpi_type}.{level} 不可解析: {label}"
def test_higher_better_labels(self):
s = build_suggestion("higher_better", 1000.0)
assert s["green"]["label"] == ">=800"
assert s["yellow"]["label"] == ">=500"
assert s["red"]["label"] == "<500"
# 三档构成完整划分
assert calc_level_from_exprs(900, ">=800", ">=500", "<500") == "green"
assert calc_level_from_exprs(600, ">=800", ">=500", "<500") == "yellow"
assert calc_level_from_exprs(400, ">=800", ">=500", "<500") == "red"
def test_lower_better_labels(self):
s = build_suggestion("lower_better", 100.0)
assert s["green"]["label"] == "<=120"
assert s["yellow"]["label"] == "<=200"
assert s["red"]["label"] == ">200"
assert calc_level_from_exprs(100, "<=120", "<=200", ">200") == "green"
assert calc_level_from_exprs(150, "<=120", "<=200", ">200") == "yellow"
assert calc_level_from_exprs(250, "<=120", "<=200", ">200") == "red"
def test_middle_best_labels_partition(self):
s = build_suggestion("middle_best", 100.0, tolerance=10.0)
assert s["green"]["label"] == "90~110"
# 黄 = 外环带 T±2tol(green ⊂ yellow);因判定顺序 red→green→yellow,绿先命中,
# 等价于严格分区:绿=90~110 / 黄=80~120 / 红=之外
assert s["yellow"]["label"] == "80~120"
assert s["red"]["label"] == "<80|>120"
g = s["green"]["label"]
y = s["yellow"]["label"]
r = s["red"]["label"]
assert calc_level_from_exprs(100, g, y, r) == "green"
assert calc_level_from_exprs(95, g, y, r) == "green" # 90~110 内 = 绿
assert calc_level_from_exprs(85, g, y, r) == "yellow" # 环带
assert calc_level_from_exprs(119, g, y, r) == "yellow" # 环带
# 边界归属与 higher_better/lower_better 保持一致:恰好落在 ±2tol 边界 → 黄(红为开区间)
assert calc_level_from_exprs(80, g, y, r) == "yellow"
assert calc_level_from_exprs(120, g, y, r) == "yellow"
assert calc_level_from_exprs(79, g, y, r) == "red"
assert calc_level_from_exprs(121, g, y, r) == "red"
def test_labels_avoid_scientific_notation(self):
"""大数目标(>=1e6)不得生成 1.6e+06 这类科学计数法(解析器不认)。"""
s = build_suggestion("higher_better", 2_000_000.0)
assert s["green"]["label"] == ">=1600000"
assert s["yellow"]["label"] == ">=1000000"
assert s["red"]["label"] == "<1000000"
for level in ("green", "yellow", "red"):
assert is_valid_threshold(s[level]["label"]), s[level]["label"]
s2 = build_suggestion("middle_best", 1_500_000.0, tolerance=100_000.0)
for level in ("green", "yellow", "red"):
assert is_valid_threshold(s2[level]["label"]), s2[level]["label"]
def test_middle_best_zero_tolerance_guard(self):
"""容忍度退化时不得生成 A~A 之类不可用表达式"""
s = build_suggestion("middle_best", 0.0, tolerance=0.0)
for level in ("green", "yellow", "red"):
assert is_valid_threshold(s[level]["label"])
class TestThresholdValidation:
"""缺陷2:非法格式必须被拒(接口层 422),不再静默入库"""
def test_validate_helper(self):
assert validate_threshold_format({"threshold_green": ">=800"}) == []
assert validate_threshold_format({"threshold_yellow": ""}) == []
assert validate_threshold_format({"threshold_red": None}) == []
errs = validate_threshold_format({"threshold_yellow": "偏离55%"})
assert len(errs) == 1 and "阈值格式非法" in errs[0]
def test_update_kpi_rejects_bad_threshold(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
resp = client.put(f"/api/cma/kpis/{kpi.id}", headers=auth_header(token),
json={"threshold_yellow": "偏离55%"})
assert resp.status_code == 422
assert "阈值格式非法" in str(resp.json())
def test_update_kpi_accepts_valid_thresholds(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
for good in (">=800", ">=-100000", "52.33~107.67", "<=36|>=124", ""):
resp = client.put(f"/api/cma/kpis/{kpi.id}", headers=auth_header(token),
json={"threshold_green": good})
assert resp.status_code == 200, f"{good} 应被接受"
class TestSuggestApi:
"""接口级:suggest 返回的 label 可解析 + 挂 format_hint"""
def test_suggest_by_type_parseable(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_REVENUE", target_value=1000)
resp = client.get(f"/api/cma/thresholds/suggest/{kpi.id}", headers=auth_header(token))
assert resp.status_code == 200
body = resp.json()
labels = [body["suggestion"][lv]["label"] for lv in ("green", "yellow", "red")]
for lb in labels:
assert is_valid_threshold(lb), f"建议值不可解析: {lb}"
assert body.get("format_hint")
@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""统一阈值解析器 + 红黄绿判定顺序(红灯优先)单测
任务:project-20260911-threshold-fix
覆盖:解析器(正/负/小数/区间/多条件/非法)、判定顺序、双调用方接线、存量表达式回归。
"""
import os
import sys
import pytest
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BACKEND not in sys.path:
sys.path.insert(0, BACKEND)
from app.utils.threshold_parser import ( # noqa: E402
LEVEL_PRIORITY,
calc_level,
calc_level_from_exprs,
describe_level,
eval_threshold,
is_valid_threshold,
parse_clauses,
parse_threshold,
)
class _Kpi:
"""轻量替身:只需要 3 个阈值属性"""
def __init__(self, g=None, y=None, r=None):
self.threshold_green = g
self.threshold_yellow = y
self.threshold_red = r
# ───────────────────────── ① 解析器 ─────────────────────────
@pytest.mark.parametrize("expr,expected", [
(">=800", (">=", 800.0)),
("<=25", ("<=", 25.0)),
(">60", (">", 60.0)),
("<20", ("<", 20.0)),
("=100", ("=", 100.0)),
("!=0", ("!=", 0.0)),
(">=-100000", (">=", -100000.0)), # 缺陷3:负数
("<-100000", ("<", -100000.0)),
("<-1.5", ("<", -1.5)),
(">=1.0", (">=", 1.0)),
(">= 800", (">=", 800.0)), # 允许空白
(" >=800 ", (">=", 800.0)),
("52.33~107.67", ("~", (52.33, 107.67))), # 区间
("-182678~183078", ("~", (-182678.0, 183078.0))),
])
def test_parse_threshold_ok(expr, expected):
assert parse_threshold(expr) == expected
@pytest.mark.parametrize("expr", ["偏离55%", "偏离>55%", "80%", ">=abc", "~", ">=", "52.33~",
">=1>=2", "abc", ">=800元", ">=- 100"])
def test_parse_threshold_invalid(expr):
assert parse_threshold(expr) == (None, None)
assert is_valid_threshold(expr) is False
@pytest.mark.parametrize("expr", [None, "", " "])
def test_blank_is_valid_but_no_clause(expr):
"""空串 = 不设置阈值:视为合法,但无子句。"""
assert is_valid_threshold(expr) is True
assert parse_clauses(expr) == []
assert parse_threshold(expr) == (None, None)
def test_multi_clause():
assert is_valid_threshold("<=36|>=124") is True
assert is_valid_threshold("<=36 | >=124") is True
assert is_valid_threshold("<=36|>=124") is True # 全角竖线
assert is_valid_threshold("<=36|abc") is False
assert parse_clauses("<=36|>=124") == [("<=", 36.0), (">=", 124.0)]
@pytest.mark.parametrize("actual,expr,expected", [
(800, ">=800", True), (799.99, ">=800", False),
(25, "<=25", True), (25.01, "<=25", False),
(61, ">60", True), (60, ">60", False),
(19, "<20", True), (20, "<20", False),
(-100000, ">=-100000", True), (-100001, ">=-100000", False),
(-100001, "<-100000", True),
(80, "52.33~107.67", True), (52.33, "52.33~107.67", True),
(107.67, "52.33~107.67", True), (107.68, "52.33~107.67", False),
(200, "<24.66|>135.34", True), (10, "<24.66|>135.34", True),
(80, "<24.66|>135.34", False),
(None, ">=0", False),
(5, "偏离55%", False), # 非法表达式 → 不命中(不静默乱判)
(5, "", False),
])
def test_eval_threshold(actual, expr, expected):
assert eval_threshold(actual, expr) is expected
# ─────────────────── ② 判定顺序:红灯优先 ───────────────────
def test_level_priority_is_red_first():
assert LEVEL_PRIORITY == ("red", "green", "yellow")
def test_defect5_yellow_no_longer_masks_red():
"""缺陷5 实测:id=6 G='<=76' Y='>76' R='>91',值 95 必须判红(原来判黄)。"""
kpi = _Kpi(g="<=76", y=">76", r=">91")
assert calc_level(95, kpi) == "red" # 关键用例
assert calc_level(92, kpi) == "red"
assert calc_level(91, kpi) == "yellow" # 未过 >91 → 黄
assert calc_level(80, kpi) == "yellow"
assert calc_level(76, kpi) == "green" # <=76 → 绿
assert calc_level(70, kpi) == "green"
def test_green_beats_wide_yellow():
"""id=1 实测:G='>=800' Y='>=700',值 900 不能判黄。"""
kpi = _Kpi(g=">=800", y=">=700", r="<600")
assert calc_level(900, kpi) == "green"
assert calc_level(750, kpi) == "yellow"
assert calc_level(500, kpi) == "red"
assert calc_level(650, kpi) == "info" # 600~700 无命中
def test_negative_thresholds():
"""id=32:Y='>=-100000' R='<-100000' 存量负数表达式(缺陷3)。"""
kpi = _Kpi(g=">=0", y=">=-100000", r="<-100000")
assert calc_level(100, kpi) == "green"
assert calc_level(-50000, kpi) == "yellow"
assert calc_level(-200000, kpi) == "red"
def test_moderate_range_semantics():
"""区间型(middle_best):绿内区间 → 黄外环 → 红区间外。"""
kpi = _Kpi(g="52.33~107.67", y="24.66~135.34", r="<24.66|>135.34")
assert calc_level(80, kpi) == "green"
assert calc_level(52.8, kpi) == "green"
assert calc_level(124.1, kpi) == "yellow"
assert calc_level(30, kpi) == "yellow"
assert calc_level(200, kpi) == "red"
assert calc_level(10, kpi) == "red"
def test_no_threshold_and_none_actual():
assert calc_level(100, _Kpi()) == "info"
assert calc_level(None, _Kpi(g=">=0", y=">=0", r=">=0")) == "info"
assert calc_level_from_exprs(50, green=None, yellow=None, red=None) == "info"
def test_describe_level():
kpi = _Kpi(g="<=76", y=">76", r=">91")
assert describe_level(95, kpi) == ("red", ">91")
assert describe_level(80, kpi) == ("yellow", ">76")
assert describe_level(70, kpi) == ("green", "<=76")
assert describe_level(0, kpi) == ("green", "<=76")
assert describe_level(None, kpi) == ("info", None)
# ─────────────────── ③ 双调用方接线(消除双实现) ───────────────────
def test_alert_generator_uses_unified_parser():
"""alert_generator 必须委托统一解析器(负数/区间/多条件都生效)。"""
from scripts import alert_generator as ag
assert ag._parse_threshold(">=-100000") == (">=", -100000.0)
assert ag._parse_threshold("52.33~107.67") == ("~", (52.33, 107.67))
trig, msg = ag._check_threshold(95, ">91", "red")
assert trig is True and ">91" in msg
assert ag._check_threshold(95, ">91x", "red") == (False, "")
assert ag._check_threshold(None, ">91", "red") == (False, "")
# 区间 + 多条件(旧实现做不到)
assert ag._check_threshold(124.1, "24.66~135.34", "yellow")[0] is True
assert ag._check_threshold(10, "<24.66|>135.34", "red")[0] is True
def test_alert_generator_loop_order_is_red_first():
"""判定循环顺序必须与 LEVEL_PRIORITY 一致(红灯优先)。"""
import inspect
from scripts import alert_generator as ag
src = inspect.getsource(ag.run_alert_check)
assert "for level in LEVEL_PRIORITY" in src
assert 'for level in ["green", "yellow", "red"]' not in src
def test_kpis_static_threshold_level_uses_unified_parser():
from app.api.kpis import _static_threshold_level
kpi = _Kpi(g=">=0", y=">=-100000", r="<-100000")
assert _static_threshold_level(-200000, kpi) == "red"
assert _static_threshold_level(-50000, kpi) == "yellow"
assert _static_threshold_level(100, kpi) == "green"
def test_kpis_validate_threshold_format():
from app.api.kpis import validate_threshold_format
assert validate_threshold_format({}) == []
assert validate_threshold_format({"threshold_green": ">=800", "threshold_yellow": "",
"threshold_red": None}) == []
errs = validate_threshold_format({"threshold_yellow": "偏离55%"})
assert len(errs) == 1 and "threshold_yellow" in errs[0]
errs2 = validate_threshold_format({"threshold_green": "80%", "threshold_red": "偏离>55%"})
assert len(errs2) == 2
def test_suggest_labels_are_parseable():
"""suggest 生成的 label 必须可解析(缺陷1 根因)。"""
from app.api import thresholds as th
def _walk(obj, acc):
if isinstance(obj, dict):
if "label" in obj and obj.get("label") is not None:
acc.append(obj["label"])
for v in obj.values():
_walk(v, acc)
elif isinstance(obj, list):
for v in obj:
_walk(v, acc)
class _K:
def __init__(self, code, target):
self.id = 1
self.kpi_code = code
self.kpi_name = code
self.target_value = target
for code in ("SALES_TOTAL", "COST_CONTROL_RATE", "TOP5_CUSTOMER_RATIO", "MADE_UP_CODE"):
out = th.suggest_by_type(_K(code, 80.0))
labels = []
_walk(out, labels)
assert labels, code
for lb in labels:
assert is_valid_threshold(lb), f"{code} 生成了不可解析 label: {lb!r}"
def test_existing_266_expressions_still_parse():
"""回归:库里现存的表达式语法必须全部可解析(除已修正的存量 4 条外为 0 不可解析)。"""
from app.database import get_session_local
from app.models import KPIDefinition
try:
db = get_session_local()()
except Exception as e: # 无DB环境(CI)时跳过
pytest.skip(f"DB 不可用: {e}")
try:
rows = db.query(KPIDefinition).filter(
(KPIDefinition.threshold_green.isnot(None)) |
(KPIDefinition.threshold_yellow.isnot(None)) |
(KPIDefinition.threshold_red.isnot(None))
).all()
bad = []
for k in rows:
for f in ("threshold_green", "threshold_yellow", "threshold_red"):
e = getattr(k, f)
if e and str(e).strip() and not is_valid_threshold(e):
bad.append((k.id, k.kpi_code, f, str(e)))
assert bad == [], f"仍存在不可解析阈值: {bad}"
finally:
db.close()
+59 -8
View File
@@ -128,11 +128,37 @@
<el-button size="small" type="primary" plain @click="associateMap">关联</el-button> <el-button size="small" type="primary" plain @click="associateMap">关联</el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="绿灯阈值"> <el-form-item>
<div style="display:flex;gap:8px;"><el-input v-model="kpi.threshold_green" /><el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button></div> <template #label>
绿灯阈值
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<div style="display:flex;gap:8px;width:100%;">
<el-input v-model="kpi.threshold_green" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
<el-button size="small" type="primary" plain @click="suggestThreshold">自动建议</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="黄灯阈值"><el-input v-model="kpi.threshold_yellow" /></el-form-item> <el-form-item>
<el-form-item label="红灯阈值"><el-input v-model="kpi.threshold_red" /></el-form-item> <template #label>
黄灯阈值
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input v-model="kpi.threshold_yellow" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
</el-form-item>
<el-form-item>
<template #label>
红灯阈值
<el-tooltip :content="THRESHOLD_HELP_TEXT" placement="top">
<el-icon style="margin-left:4px;vertical-align:-2px;cursor:help;color:#909399;"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input v-model="kpi.threshold_red" placeholder="如 >=800 / 52.33~107.67" clearable style="width:100%" />
</el-form-item>
<div style="color:#909399;font-size:12px;line-height:1.5;margin-top:-6px;">{{ THRESHOLD_HELP_TEXT }}</div>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
@@ -354,6 +380,7 @@
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import 'echarts' import 'echarts'
import { kpiApi, mapApi, kpiCausalityApi, orgApi, userApi } from '../api/index' import { kpiApi, mapApi, kpiCausalityApi, orgApi, userApi } from '../api/index'
@@ -737,8 +764,23 @@ const fullNetworkChartOption = computed(() => {
} }
}) })
// 阈值语法与后端 app/utils/threshold_parser.py 保持一致
const THRESHOLD_CLAUSE_RE = /^(?:-?\d+(?:\.\d+)?\s*~\s*-?\d+(?:\.\d+)?|(?:>=|<=|!=|>|<|=)\s*-?\d+(?:\.\d+)?)$/
const THRESHOLD_HELP_TEXT = '支持格式:>=800 / <=25 / >60 / <20 / =100 / !=0(数值可为负数,如 <-100000);区间 52.33~107.67(含端点);多条件用 | 连接,如 <=36|>=124。'
function isValidThreshold(v: any): boolean {
const s = String(v ?? '').trim()
if (!s) return true
return s.split(/[||]/).every(c => THRESHOLD_CLAUSE_RE.test(c.trim()))
}
async function save() { async function save() {
if (saving.value) return if (saving.value) return
const invalid = ([['绿灯阈值', kpi.value.threshold_green], ['黄灯阈值', kpi.value.threshold_yellow], ['红灯阈值', kpi.value.threshold_red]] as [string, any][])
.filter(([, v]) => !isValidThreshold(v))
if (invalid.length) {
ElMessage.error(`${invalid.map(([n]) => n).join('、')}格式非法;${THRESHOLD_HELP_TEXT}`)
return
}
saving.value = true saving.value = true
try { try {
// 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑) // 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑)
@@ -767,10 +809,18 @@ async function suggestThreshold() {
const r: any = await api.get('/thresholds/suggest/' + kpi.value.id) const r: any = await api.get('/thresholds/suggest/' + kpi.value.id)
const s = r.suggestion const s = r.suggestion
if (!s) { ElMessage.warning('暂无建议数据'); return } if (!s) { ElMessage.warning('暂无建议数据'); return }
kpi.value.threshold_green = s.green?.label || '' const g = s.green?.label || ''
kpi.value.threshold_yellow = s.yellow?.label || '' const y = s.yellow?.label || ''
kpi.value.threshold_red = s.red?.label || '' const rd = s.red?.label || ''
ElMessage.success('已自动填入推荐阈值') // 建议值必须自身可解析(防填入不可解析格式 → 静默失效)
if (!isValidThreshold(g) || !isValidThreshold(y) || !isValidThreshold(rd)) {
ElMessage.error('自动建议生成值格式非法,请检查后端 suggest 接口')
return
}
kpi.value.threshold_green = g
kpi.value.threshold_yellow = y
kpi.value.threshold_red = rd
ElMessage.success('已自动填入推荐阈值(格式已校验)')
} catch (e) { } catch (e) {
ElMessage.error('获取阈值建议失败') ElMessage.error('获取阈值建议失败')
} }
@@ -900,6 +950,7 @@ function onMapChange(val: any) {}
.kpi-detail-page .header-card .header-title { font-size: 16px; font-weight: 600; } .kpi-detail-page .header-card .header-title { font-size: 16px; font-weight: 600; }
.kpi-detail-page .header-card .header-tag { margin-left: 8px; } .kpi-detail-page .header-card .header-tag { margin-left: 8px; }
.kpi-detail-page .header-card .header-tag-gap { margin-left: 4px; } .kpi-detail-page .header-card .header-tag-gap { margin-left: 4px; }
.threshold-help { cursor: help; color: #909399; font-size: 12px; line-height: 28px; white-space: nowrap; }
.full-width-chart { height: 300px; width: 100%; } .full-width-chart { height: 300px; width: 100%; }
.data-table { width: 100%; margin-top: 12px; } .data-table { width: 100%; margin-top: 12px; }
.network-empty { color: #999; text-align: center; padding: 20px; } .network-empty { color: #999; text-align: center; padding: 20px; }