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:
+27
-20
@@ -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改企业归属
|
||||
|
||||
+115
-57
@@ -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|>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}
|
||||
|
||||
Reference in New Issue
Block a user