diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index c74414ac..783d539d 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -5,13 +5,17 @@ from sqlalchemy import func from typing import Optional, List from datetime import datetime import json -import re from app.database import get_db 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.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.utils.threshold_parser import ( + calc_level as _tp_calc_level, + is_valid_threshold, + THRESHOLD_HELP, +) router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"], 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: - """按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 info。 - 供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致。""" - for level, field in (("green", "threshold_green"), ("yellow", "threshold_yellow"), - ("red", "threshold_red")): - expr = getattr(kpi, field, None) - 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" + """按 KPI 静态阈值文本判定级别(统一解析器,判定顺序 red→green→yellow),无阈值返回 info。 + + 供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致: + 解析与判定逻辑统一收敛到 app.utils.threshold_parser(单一事实源,2026-09-11 修复双实现+红灯遮蔽)。""" + return _tp_calc_level(value, kpi) 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 +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("") 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']} 已存在") # 数据治理校验(规则1强制拦截) errs = _validate_kpi_data(data, db=db, is_update=False) + errs = errs + validate_threshold_format(data) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) 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不存在") # 数据治理校验(更新时只检查传了但为空的字段) errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True) + errs = errs + validate_threshold_format(data) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) data.pop("entity_id", None) # 禁止通过update改企业归属 diff --git a/backend/app/api/thresholds.py b/backend/app/api/thresholds.py index d4da110c..9e3d09f1 100644 --- a/backend/app/api/thresholds.py +++ b/backend/app/api/thresholds.py @@ -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 sqlalchemy.orm import Session from app.database import get_db from app.auth_middleware import require_auth, require_role 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 router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"], @@ -18,9 +26,97 @@ KPI_TYPES = { "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` + """ + 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}") def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)): - """根据历史数据自动推荐阈值""" + """根据历史数据自动推荐阈值(输出格式保证被 threshold_parser 解析生效)""" kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: return {"error": "KPI不存在"} @@ -36,62 +132,24 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)): if len(nums) < 2: 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) - std = variance ** 0.5 + variance: Any = sum((x - avg) ** 2 for x in nums) / len(nums) + std: Any = variance ** 0.5 - target = kpi.target_value or avg - - # 根据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} + target: Any = kpi.target_value or avg + kpi_type = _kpi_type(kpi) -def suggest_by_type(kpi): - """无历史数据时按类型推荐""" - target = kpi.target_value or 100 - 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)}}} + if kpi_type == "middle_best": + tolerance: Any = max(std * 1.5, target * 0.2) + suggestion = build_suggestion(kpi_type, target, tolerance) else: - return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据", - "suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}} + suggestion = build_suggestion(kpi_type, target) + 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} diff --git a/backend/app/utils/threshold_parser.py b/backend/app/utils/threshold_parser.py new file mode 100644 index 00000000..e0eba9e8 --- /dev/null +++ b/backend/app/utils/threshold_parser.py @@ -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 =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) diff --git a/backend/scripts/alert_generator.py b/backend/scripts/alert_generator.py index 0e6532c8..adced5a7 100644 --- a/backend/scripts/alert_generator.py +++ b/backend/scripts/alert_generator.py @@ -6,7 +6,6 @@ import sys import os -import re import json import logging 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.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan +from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY logger = logging.getLogger("cma.alert_gen") def _parse_threshold(expr: str) -> tuple: - """解析阈值表达式,返回 (operator, value) - 示例: - >=32000000 -> ('>=', 32000000) - <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 + """[兼容保留] 统一委托 app.utils.threshold_parser.parse_threshold""" + from app.utils.threshold_parser import parse_threshold as _p + return _p(expr) 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: - """检查实际值是否触发阈值,返回 (触发, 消息)""" + """检查实际值是否触发阈值,返回 (触发, 消息)(统一走 app.utils.threshold_parser)""" if not threshold_expr or actual is None: return False, "" - op, val = _parse_threshold(threshold_expr) - if op is None: + if not eval_threshold(actual, threshold_expr): return False, "" - triggered = False - if 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 - 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, "" + msg = (f"KPI当前值 {actual:.2f},触发{ {'green': '正常', 'yellow': '预警', 'red': '紧急'}.get(level, level) }阈值 " + f"({threshold_expr})") + return True, msg 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 - # 从绿到红检查(绿灯最高优先级——满足即止) + # 判定顺序:红灯优先 → 绿灯 → 黄灯(最严重者优先,消除宽黄灯遮蔽红灯) + # 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落库 - for level in ["green", "yellow", "red"]: + for level in LEVEL_PRIORITY: expr = thr[level] if not expr: continue @@ -173,7 +151,8 @@ def run_alert_check(db_session, period: str = None) -> int: logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}") break # 只取最高级别 elif level == "red" and not triggered: - # 红没触发,如果已有红色预警但当前不满足,自动降级或关闭 + # 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯, + # 说明当前值已不再触发红色阈值,自动解除(保留原有自动降级行为) existing_red = db_session.query(KPIAlert).filter( KPIAlert.kpi_id == kpi.id, KPIAlert.kpi_value_id == latest.id, diff --git a/backend/scripts/backups/threshold_regression_20260911.json b/backend/scripts/backups/threshold_regression_20260911.json new file mode 100644 index 00000000..7a6b258e --- /dev/null +++ b/backend/scripts/backups/threshold_regression_20260911.json @@ -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 +} \ No newline at end of file diff --git a/backend/scripts/backups/threshold_rows_before_20260911.json b/backend/scripts/backups/threshold_rows_before_20260911.json new file mode 100644 index 00000000..1720338d --- /dev/null +++ b/backend/scripts/backups/threshold_rows_before_20260911.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/backend/scripts/fix_threshold_data_20260911.py b/backend/scripts/fix_threshold_data_20260911.py new file mode 100644 index 00000000..b2e20003 --- /dev/null +++ b/backend/scripts/fix_threshold_data_20260911.py @@ -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() diff --git a/backend/scripts/verify_threshold_regression_20260911.py b/backend/scripts/verify_threshold_regression_20260911.py new file mode 100644 index 00000000..85059032 --- /dev/null +++ b/backend/scripts/verify_threshold_regression_20260911.py @@ -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() diff --git a/backend/tests/test_threshold_parser.py b/backend/tests/test_threshold_parser.py new file mode 100644 index 00000000..197a8005 --- /dev/null +++ b/backend/tests/test_threshold_parser.py @@ -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") diff --git a/backend/tests/test_threshold_parser_20260911.py b/backend/tests/test_threshold_parser_20260911.py new file mode 100644 index 00000000..04317c52 --- /dev/null +++ b/backend/tests/test_threshold_parser_20260911.py @@ -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() diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue index 3ea80d0f..b824d16f 100644 --- a/frontend/src/views/KPIDetail.vue +++ b/frontend/src/views/KPIDetail.vue @@ -128,11 +128,37 @@ 关联 - -
自动建议
+ + +
+ + 自动建议 +
- - + + + + + + + + +
{{ THRESHOLD_HELP_TEXT }}
@@ -354,6 +380,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { useRoute } from 'vue-router' import { ElMessage } from 'element-plus' +import { QuestionFilled } from '@element-plus/icons-vue' import VChart from 'vue-echarts' import 'echarts' 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() { 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 try { // 多粒度目标同步:按frequency将对应周期目标写入target_value(兼容评分/阈值等旧逻辑) @@ -767,10 +809,18 @@ async function suggestThreshold() { const r: any = await api.get('/thresholds/suggest/' + kpi.value.id) const s = r.suggestion if (!s) { ElMessage.warning('暂无建议数据'); return } - kpi.value.threshold_green = s.green?.label || '' - kpi.value.threshold_yellow = s.yellow?.label || '' - kpi.value.threshold_red = s.red?.label || '' - ElMessage.success('已自动填入推荐阈值') + const g = s.green?.label || '' + const y = s.yellow?.label || '' + const rd = s.red?.label || '' + // 建议值必须自身可解析(防填入不可解析格式 → 静默失效) + 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) { 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-tag { margin-left: 8px; } .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%; } .data-table { width: 100%; margin-top: 12px; } .network-empty { color: #999; text-align: center; padding: 20px; }