fix: 战略地图P0修复 — KPI code统一+放开同层连线+数据迁移
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+463
-60
@@ -1,76 +1,479 @@
|
||||
"""预警规则配置"""
|
||||
""""
|
||||
预警规则智能化 — 任务6
|
||||
后端组件: alert_rules 模型 + API + 预警引擎
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db, Base
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, KPIDefinition, KPIValue
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
# ============================================================
|
||||
# AlertRule 模型
|
||||
# ============================================================
|
||||
class AlertRule(Base):
|
||||
"""预警规则配置"""
|
||||
__tablename__ = "alert_rules"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
|
||||
enabled = Column(Integer, default=1, comment="1启用 0禁用")
|
||||
params = Column(JSON, nullable=True, comment="规则参数")
|
||||
# static: {"green": ">=90", "yellow": ">=80", "red": "<80"}
|
||||
# dynamic: {"sensitivity": 1.0} — 阈值 = mean ± sensitivity * stddev, period_months=3
|
||||
# trend_up: {"threshold_pct": 10} — 环比上升超过 threshold_pct% 触发
|
||||
# trend_down: {"threshold_pct": 10} — 环比下降超过 threshold_pct% 触发
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class DynamicThresholdCache(Base):
|
||||
"""动态阈值缓存 — 存储近3个月历史统计"""
|
||||
__tablename__ = "dynamic_threshold_cache"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
|
||||
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
|
||||
mean_value = Column(Float, nullable=True, comment="近3月均值")
|
||||
stddev_value = Column(Float, nullable=True, comment="近3月标准差")
|
||||
dynamic_green = Column(String(100), nullable=True, comment="动态绿灯阈值")
|
||||
dynamic_yellow = Column(String(100), nullable=True, comment="动态黄灯阈值")
|
||||
dynamic_red = Column(String(100), nullable=True, comment="动态红灯阈值")
|
||||
calculated_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alert-rules", tags=["预警规则"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
def list_rules(kpi_id: int = None, db: Session = Depends(get_db)):
|
||||
"""获取预警规则(从KPI定义中读取阈值配置)"""
|
||||
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_id:
|
||||
query = query.filter(KPIDefinition.id == kpi_id)
|
||||
rules = []
|
||||
for k in query.all():
|
||||
if k.threshold_green or k.threshold_yellow or k.threshold_red:
|
||||
rules.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_name": k.kpi_name,
|
||||
"threshold_green": k.threshold_green,
|
||||
"threshold_yellow": k.threshold_yellow,
|
||||
"threshold_red": k.threshold_red,
|
||||
})
|
||||
return {"data": rules}
|
||||
|
||||
@router.post("/check/{kpi_id}")
|
||||
def check_alert(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""检查指定KPI是否需要触发预警"""
|
||||
# ============================================================
|
||||
# API Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_alert_rules(
|
||||
kpi_id: Optional[int] = None,
|
||||
rule_type: Optional[str] = None,
|
||||
enabled: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出所有预警规则"""
|
||||
query = db.query(AlertRule)
|
||||
if kpi_id:
|
||||
query = query.filter(AlertRule.kpi_id == kpi_id)
|
||||
if rule_type:
|
||||
query = query.filter(AlertRule.rule_type == rule_type)
|
||||
if enabled is not None:
|
||||
query = query.filter(AlertRule.enabled == enabled)
|
||||
|
||||
rules = query.order_by(AlertRule.id).all()
|
||||
result = []
|
||||
for r in rules:
|
||||
d = {c.name: getattr(r, c.name) for c in AlertRule.__table__.columns}
|
||||
# 关联KPI信息
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
|
||||
if kpi:
|
||||
d["kpi_code"] = kpi.kpi_code
|
||||
d["kpi_name"] = kpi.kpi_name
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}")
|
||||
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个KPI的所有预警规则"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
|
||||
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it"))):
|
||||
"""创建预警规则"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type", "static")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if rule_type not in ("static", "dynamic", "trend_up", "trend_down"):
|
||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
||||
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=data.get("enabled", 1),
|
||||
params=data.get("params"),
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).first()
|
||||
if not latest or not latest.actual_value:
|
||||
return {"alert": False, "message": "无数据"}
|
||||
# 日志
|
||||
db.add(OperationLog(
|
||||
action="create_alert_rule", target_type="alert_rule",
|
||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
val = latest.actual_value
|
||||
level = "green"
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "预警规则不存在")
|
||||
|
||||
# 简单阈值判定
|
||||
red = kpi.threshold_red
|
||||
yellow = kpi.threshold_yellow
|
||||
|
||||
# 红灯判断: <3000000 表示低于300万触发红灯
|
||||
if red:
|
||||
if "<" in red:
|
||||
limit = float(red.split("<")[1].strip())
|
||||
if val < limit: level = "red"
|
||||
elif ">" in red:
|
||||
limit = float(red.split(">")[1].strip())
|
||||
if val > limit: level = "red"
|
||||
|
||||
# 黄灯判断(红灯未触发时)
|
||||
if level == "green" and yellow:
|
||||
if "<" in yellow:
|
||||
limit = float(yellow.split("<")[1].strip())
|
||||
if val < limit: level = "yellow"
|
||||
elif ">" in yellow:
|
||||
limit = float(yellow.split(">")[1].strip())
|
||||
if val > limit: level = "yellow"
|
||||
|
||||
if level != "green":
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi_id, kpi_value_id=latest.id,
|
||||
alert_level=level,
|
||||
alert_message=f"{kpi.kpi_name}当前值为{val},触发{level}预警",
|
||||
)
|
||||
db.add(alert)
|
||||
for field in ("rule_type", "enabled", "params"):
|
||||
if field in data:
|
||||
setattr(rule, field, data[field])
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
|
||||
"""删除预警规则"""
|
||||
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||
if rule:
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
return {"alert": True, "level": level, "message": alert.alert_message}
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
|
||||
"""批量创建预警规则
|
||||
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
|
||||
"""
|
||||
rules_data = data.get("rules", [])
|
||||
created = 0
|
||||
for rule_data in rules_data:
|
||||
kpi_id = rule_data.get("kpi_id")
|
||||
rule_type = rule_data.get("rule_type", "static")
|
||||
# 检查是否已存在相同类型的规则
|
||||
existing = db.query(AlertRule).filter(
|
||||
AlertRule.kpi_id == kpi_id,
|
||||
AlertRule.rule_type == rule_type,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
enabled=rule_data.get("enabled", 1),
|
||||
params=rule_data.get("params"),
|
||||
)
|
||||
db.add(rule)
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"批量创建完成: 新增{created}条", "created": created}
|
||||
|
||||
|
||||
@router.post("/generate-defaults")
|
||||
def generate_default_rules(db: Session = Depends(get_db)):
|
||||
"""为所有尚未配置预警规则的KPI生成默认规则"""
|
||||
# 找到所有active KPI
|
||||
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
|
||||
return {"alert": False, "level": "green", "message": "正常"}
|
||||
created = 0
|
||||
for kpi in all_kpis:
|
||||
# 检查是否已有任何规则
|
||||
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
kpi_id = kpi.id
|
||||
|
||||
# 1. 静态阈值规则(基于kpi_definitions的阈值)
|
||||
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
|
||||
rule = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type="static",
|
||||
enabled=1,
|
||||
params={
|
||||
"green": kpi.threshold_green,
|
||||
"yellow": kpi.threshold_yellow,
|
||||
"red": kpi.threshold_red,
|
||||
}
|
||||
)
|
||||
db.add(rule)
|
||||
created += 1
|
||||
|
||||
# 2. 动态趋势规则(所有KPI默认加 trend_down)
|
||||
rule2 = AlertRule(
|
||||
kpi_id=kpi_id,
|
||||
rule_type="trend_down",
|
||||
enabled=1,
|
||||
params={"threshold_pct": 10},
|
||||
)
|
||||
db.add(rule2)
|
||||
created += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"默认规则生成完成: 共{created}条", "created": created}
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
def run_all_alert_checks(db: Session = Depends(get_db)):
|
||||
"""执行所有KPI的预警检查 — 生成新的预警记录"""
|
||||
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
|
||||
kpi_cache = {}
|
||||
value_cache = {}
|
||||
|
||||
alerts_generated = 0
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
kpi = kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
if kpi:
|
||||
kpi_cache[rule.kpi_id] = kpi
|
||||
|
||||
if not kpi:
|
||||
continue
|
||||
|
||||
# 获取最新值
|
||||
latest_value = value_cache.get(rule.kpi_id)
|
||||
if latest_value is None:
|
||||
latest_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.kpi_id,
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
if latest_value:
|
||||
value_cache[rule.kpi_id] = latest_value
|
||||
|
||||
if not latest_value or latest_value.actual_value is None:
|
||||
continue
|
||||
|
||||
value = latest_value.actual_value
|
||||
period = latest_value.period
|
||||
params = rule.params or {}
|
||||
|
||||
alert_level = None
|
||||
alert_message = None
|
||||
|
||||
if rule.rule_type == "static":
|
||||
alert_level, alert_message = _check_static(value, params, kpi)
|
||||
elif rule.rule_type == "dynamic":
|
||||
alert_level, alert_message = _check_dynamic(kpi.id, value, params, db)
|
||||
elif rule.rule_type == "trend_up":
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "up", params, db)
|
||||
elif rule.rule_type == "trend_down":
|
||||
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
|
||||
|
||||
if alert_level and alert_level != "green":
|
||||
# 检查是否已有相同预警
|
||||
existing_alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == rule.kpi_id,
|
||||
KPIAlert.kpi_value_id == latest_value.id,
|
||||
KPIAlert.alert_level == alert_level,
|
||||
KPIAlert.alert_message == alert_message,
|
||||
KPIAlert.status == "pending",
|
||||
).first()
|
||||
if not existing_alert:
|
||||
alert = KPIAlert(
|
||||
kpi_id=rule.kpi_id,
|
||||
kpi_value_id=latest_value.id,
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_message,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
alerts_generated += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"预警检查失败: rule_id={rule.id}, error={e}")
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
|
||||
|
||||
|
||||
@router.get("/dynamic-thresholds")
|
||||
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""获取动态阈值缓存"""
|
||||
query = db.query(DynamicThresholdCache)
|
||||
if kpi_id:
|
||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
||||
return {"data": [{c.name: getattr(c, c.name) for c in DynamicThresholdCache.__table__.columns} for c in cache]}
|
||||
|
||||
|
||||
@router.post("/calculate-dynamic")
|
||||
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
|
||||
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
computed = 0
|
||||
for kpi in kpis:
|
||||
# 取近3个月的历史值(不含当月)
|
||||
from sqlalchemy import text as sa_text, func as sa_func
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
KPIValue.data_status.in_(["verified", "estimated"]),
|
||||
KPIValue.period < current_period,
|
||||
).order_by(KPIValue.period.desc()).limit(3).all()
|
||||
|
||||
if len(values) < 2:
|
||||
continue
|
||||
|
||||
vals = [v.actual_value for v in values if v.actual_value is not None]
|
||||
if len(vals) < 2:
|
||||
continue
|
||||
|
||||
mean_val = sum(vals) / len(vals)
|
||||
if len(vals) > 1:
|
||||
variance = sum((v - mean_val) ** 2 for v in vals) / len(vals)
|
||||
stddev = variance ** 0.5
|
||||
else:
|
||||
stddev = mean_val * 0.1 # 仅1个值时的合理估算
|
||||
|
||||
# 生成动态阈值(±1标准差)
|
||||
dynamic_green = f">={mean_val + stddev:.2f}"
|
||||
dynamic_yellow = f">={mean_val:.2f}"
|
||||
dynamic_red = f"<{mean_val:.2f}"
|
||||
|
||||
# 检查是否已有缓存
|
||||
existing = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.kpi_id == kpi.id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.mean_value = mean_val
|
||||
existing.stddev_value = stddev
|
||||
existing.dynamic_green = dynamic_green
|
||||
existing.dynamic_yellow = dynamic_yellow
|
||||
existing.dynamic_red = dynamic_red
|
||||
else:
|
||||
cache = DynamicThresholdCache(
|
||||
kpi_id=kpi.id,
|
||||
period=current_period,
|
||||
mean_value=mean_val,
|
||||
stddev_value=stddev,
|
||||
dynamic_green=dynamic_green,
|
||||
dynamic_yellow=dynamic_yellow,
|
||||
dynamic_red=dynamic_red,
|
||||
)
|
||||
db.add(cache)
|
||||
computed += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"动态阈值计算完成: {computed}个KPI", "computed": computed}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 检查引擎
|
||||
# ============================================================
|
||||
|
||||
def _check_static(value: float, params: dict, kpi) -> tuple:
|
||||
"""静态阈值检查"""
|
||||
green = params.get("green")
|
||||
yellow = params.get("yellow")
|
||||
red = params.get("red")
|
||||
|
||||
# 从KPI定义获取阈值
|
||||
if not green and not yellow and not red:
|
||||
green = kpi.threshold_green
|
||||
yellow = kpi.threshold_yellow
|
||||
red = kpi.threshold_red
|
||||
|
||||
if _eval_threshold(value, green):
|
||||
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
|
||||
elif _eval_threshold(value, yellow):
|
||||
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
|
||||
elif red and _eval_threshold(value, red, invert=True):
|
||||
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _check_dynamic(kpi_id: int, value: float, params: dict, db: Session) -> tuple:
|
||||
"""动态阈值检查 — 基于历史均值±标准差"""
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
cache = db.query(DynamicThresholdCache).filter(
|
||||
DynamicThresholdCache.kpi_id == kpi_id,
|
||||
DynamicThresholdCache.period == current_period,
|
||||
).first()
|
||||
|
||||
if not cache:
|
||||
return (None, None)
|
||||
|
||||
sensitivity = params.get("sensitivity", 1.0)
|
||||
mean_val = cache.mean_value or 0
|
||||
stddev_val = (cache.stddev_value or 0) * sensitivity
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
|
||||
|
||||
if value >= mean_val + stddev_val:
|
||||
return ("green", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
elif value >= mean_val:
|
||||
return ("yellow", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
else:
|
||||
return ("red", f"[动态] {kpi_name}={value}, 低于均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
|
||||
|
||||
|
||||
def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Session) -> tuple:
|
||||
"""趋势检查 — 环比变化"""
|
||||
threshold_pct = params.get("threshold_pct", 10)
|
||||
|
||||
# 获取上月值
|
||||
prev_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
|
||||
|
||||
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
|
||||
return (None, None)
|
||||
|
||||
change_pct = round((value - prev_value.actual_value) / abs(prev_value.actual_value) * 100, 2)
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
|
||||
|
||||
if direction == "up" and change_pct > threshold_pct:
|
||||
level = "yellow" if change_pct < threshold_pct * 2 else "red"
|
||||
return (level, f"[趋势↑] {kpi_name}环比上升{change_pct}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
|
||||
elif direction == "down" and change_pct < -threshold_pct:
|
||||
level = "yellow" if abs(change_pct) < threshold_pct * 2 else "red"
|
||||
return (level, f"[趋势↓] {kpi_name}环比下降{abs(change_pct)}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> bool:
|
||||
"""评估阈值: '>=90', '<80', '>5', '<=2' 等"""
|
||||
if not threshold_str:
|
||||
return False
|
||||
threshold_str = str(threshold_str).strip()
|
||||
|
||||
try:
|
||||
if threshold_str.startswith(">="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value >= limit if not invert else value >= limit
|
||||
elif threshold_str.startswith("<="):
|
||||
limit = float(threshold_str[2:])
|
||||
return value <= limit if not invert else value <= limit
|
||||
elif threshold_str.startswith(">"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value > limit if not invert else value > limit
|
||||
elif threshold_str.startswith("<"):
|
||||
limit = float(threshold_str[1:])
|
||||
return value < limit if not invert else value < limit
|
||||
else:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""BI报表集成 — 任务4
|
||||
分析模式 + 预置报表模板 + 报表保存/分享 + 导出
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
|
||||
logger = logging.getLogger("bi-reports")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bi-reports", tags=["BI报表"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预置模板
|
||||
# ============================================================
|
||||
|
||||
PRESET_TEMPLATES = [
|
||||
{
|
||||
"name": "四层指标总览",
|
||||
"report_type": "overview",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "展示财务/客户/流程/学习四层维度的关键KPI概览",
|
||||
"layout": "grid",
|
||||
"dimensions": ["finance", "customer", "process", "learning"],
|
||||
"metrics": ["count", "avg_value", "alert_count"],
|
||||
"chart_type": "gauge_card",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "同比趋势分析",
|
||||
"report_type": "trend",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "各KPI近12个月趋势对比",
|
||||
"period": "monthly",
|
||||
"window_months": 12,
|
||||
"chart_type": "line",
|
||||
"show_compare": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "实际vs预算对比",
|
||||
"report_type": "comparison",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "KPI实际值 vs 目标值的偏差分析",
|
||||
"chart_type": "bar",
|
||||
"show_deviation": True,
|
||||
"group_by": "dimension",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TOP N异常KPI",
|
||||
"report_type": "topn",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "排名前N的异常KPI(红/黄灯)",
|
||||
"top_n": 10,
|
||||
"sort_by": "deviation",
|
||||
"chart_type": "horizontal_bar",
|
||||
"show_threshold": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "因果链推演",
|
||||
"report_type": "causality",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "基于KPI因果链的推演分析",
|
||||
"chart_type": "force_graph",
|
||||
"max_depth": 3,
|
||||
"min_strength": 0.3,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_report_templates(db: Session = Depends(get_db)):
|
||||
"""获取BI报表模板"""
|
||||
templates = db.query(BiReportTemplate).order_by(BiReportTemplate.id).all()
|
||||
return {"data": [{c.name: getattr(t, c.name) for c in BiReportTemplate.__table__.columns} for t in templates]}
|
||||
|
||||
|
||||
@router.post("/templates/seed")
|
||||
def seed_report_templates(db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""初始化预置模板(仅首次运行)"""
|
||||
created = 0
|
||||
for tpl in PRESET_TEMPLATES:
|
||||
existing = db.query(BiReportTemplate).filter(
|
||||
BiReportTemplate.name == tpl["name"],
|
||||
BiReportTemplate.is_system == 1,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
t = BiReportTemplate(**tpl)
|
||||
db.add(t)
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"新增{created}个预置模板", "created": created}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
t = db.query(BiReportTemplate).filter(BiReportTemplate.id == template_id).first()
|
||||
if t:
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 用户报表
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_reports(db: Session = Depends(get_db)):
|
||||
"""获取用户保存的报表"""
|
||||
reports = db.query(BiReport).order_by(BiReport.updated_at.desc()).all()
|
||||
result = []
|
||||
for r in reports:
|
||||
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
d["created_by_name"] = f"用户{r.created_by}" if r.created_by else "系统"
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{report_id}")
|
||||
def get_report(report_id: int, db: Session = Depends(get_db)):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""保存BI报表"""
|
||||
r = BiReport(
|
||||
template_id=data.get("template_id"),
|
||||
name=data.get("name", "未命名报表"),
|
||||
config=data.get("config", {}),
|
||||
chart_type=data.get("chart_type", "auto"),
|
||||
is_shared=data.get("is_shared", 0),
|
||||
created_by=1,
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.put("/{report_id}")
|
||||
def update_report(report_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
for field in ("name", "config", "chart_type", "is_shared"):
|
||||
if field in data:
|
||||
setattr(r, field, data[field])
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.delete("/{report_id}")
|
||||
def delete_report(report_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if r:
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 分析引擎
|
||||
# ============================================================
|
||||
|
||||
@router.post("/analyze")
|
||||
def analyze_data(data: dict, db: Session = Depends(get_db)):
|
||||
"""分析引擎:按配置返回报表数据
|
||||
Body: {
|
||||
config: { dimensions, kpi_ids, period_start, period_end, group_by, metrics, ... },
|
||||
chart_type: str
|
||||
}
|
||||
"""
|
||||
config = data.get("config", {})
|
||||
chart_type = data.get("chart_type", "auto")
|
||||
|
||||
kpi_ids = config.get("kpi_ids", [])
|
||||
dimensions = config.get("dimensions", [])
|
||||
period_start = config.get("period_start")
|
||||
period_end = config.get("period_end")
|
||||
group_by = config.get("group_by")
|
||||
top_n = config.get("top_n", 10)
|
||||
|
||||
# 构建KPI查询
|
||||
kpi_query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_ids:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.id.in_(kpi_ids))
|
||||
if dimensions:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.dimension.in_(dimensions))
|
||||
kpis = kpi_query.order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
# 获取每个KPI的最新值
|
||||
rows = []
|
||||
for kpi in kpis:
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period_start:
|
||||
val_query = val_query.filter(KPIValue.period >= period_start)
|
||||
if period_end:
|
||||
val_query = val_query.filter(KPIValue.period <= period_end)
|
||||
|
||||
latest = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 获取趋势数据
|
||||
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
|
||||
|
||||
rows.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"unit": kpi.unit,
|
||||
"target_value": kpi.target_value,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
"current_value": latest.actual_value if latest else None,
|
||||
"current_period": latest.period if latest else None,
|
||||
"trend": [{"period": v.period, "value": v.actual_value} for v in trend_values],
|
||||
})
|
||||
|
||||
# 统计汇总
|
||||
summary = {
|
||||
"total_kpis": len(rows),
|
||||
"dimensions": {},
|
||||
}
|
||||
for r in rows:
|
||||
dim = r["dimension"]
|
||||
if dim not in summary["dimensions"]:
|
||||
summary["dimensions"][dim] = {"count": 0, "values": []}
|
||||
summary["dimensions"][dim]["count"] += 1
|
||||
if r["current_value"] is not None:
|
||||
summary["dimensions"][dim]["values"].append(r["current_value"])
|
||||
|
||||
for dim, info in summary["dimensions"].items():
|
||||
vals = info["values"]
|
||||
if vals:
|
||||
info["avg"] = round(sum(vals) / len(vals), 2)
|
||||
info["min"] = min(vals)
|
||||
info["max"] = max(vals)
|
||||
del info["values"]
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"chart_type": chart_type,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导出功能(CSV格式,前端可转为Excel/PDF)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/export")
|
||||
def export_report(data: dict, db: Session = Depends(get_db)):
|
||||
"""导出报表数据 (CSV)"""
|
||||
config = data.get("config", {})
|
||||
format_type = data.get("format", "csv")
|
||||
|
||||
# 复用analyze获取数据
|
||||
from app.database import get_session_local
|
||||
temp_db = get_session_local()()
|
||||
try:
|
||||
result = analyze_data(data, temp_db)
|
||||
finally:
|
||||
temp_db.close()
|
||||
|
||||
rows = result.get("rows", [])
|
||||
if not rows:
|
||||
raise HTTPException(400, "没有可导出的数据")
|
||||
|
||||
# 生成CSV
|
||||
import csv, io
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["KPI编码", "KPI名称", "维度", "类别", "当前值", "期间", "目标值", "单位"])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r["kpi_code"], r["kpi_name"], r["dimension"], r["category"],
|
||||
r["current_value"], r["current_period"], r["target_value"], r["unit"],
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=bi_report_{datetime.now().strftime('%Y%m%d')}.csv"},
|
||||
)
|
||||
+47
-1
@@ -8,7 +8,7 @@ from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
@@ -301,3 +301,49 @@ def delete_source(source_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(source)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.get("/sync-kpis")
|
||||
def sync_kpis_from_erp(db: Session = Depends(get_db)):
|
||||
"""从ERP数据源同步KPI值(调用erp_sync模块)"""
|
||||
from scripts.erp_sync import run_sync
|
||||
import traceback
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
# 获取所有标记为erp数据源的KPI
|
||||
erp_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.data_source_type == "erp",
|
||||
).all()
|
||||
kpi_count = len(erp_kpis)
|
||||
kpi_codes = [k.kpi_code for k in erp_kpis]
|
||||
|
||||
# 执行同步 (dry_run=False, use_api=False 使用本地fallback)
|
||||
run_sync(dry_run=False, kpi_codes=kpi_codes, use_api=False)
|
||||
|
||||
# 记录操作日志
|
||||
log = OperationLog(
|
||||
action="sync_kpis",
|
||||
target_type="kpi",
|
||||
detail=f"ERP同步: {kpi_count}个KPI, 编码: {', '.join(kpi_codes[:10])}{'...' if kpi_count > 10 else ''}",
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"ERP数据同步完成",
|
||||
"total_kpis": kpi_count,
|
||||
"kpi_codes": kpi_codes,
|
||||
"synced_at": dt.now().isoformat(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log = OperationLog(
|
||||
action="sync_kpis_error",
|
||||
target_type="kpi",
|
||||
detail=f"ERP同步失败: {str(e)[:500]}",
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
raise HTTPException(500, f"ERP同步失败: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""自动数据质量监控 — 任务3
|
||||
定期检查KPI值异常、连续持平、数据缺失等
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
|
||||
from app.api.kpis import kpi_to_dict
|
||||
|
||||
logger = logging.getLogger("data-quality")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data-quality", tags=["数据质量"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def _log_to_dict(log):
|
||||
d = {c.name: getattr(log, c.name) for c in log.__table__.columns}
|
||||
if hasattr(log, 'kpi') and log.kpi:
|
||||
d["kpi_code"] = log.kpi.kpi_code
|
||||
d["kpi_name"] = log.kpi.kpi_name
|
||||
return d
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 质量检查
|
||||
# ============================================================
|
||||
|
||||
@router.get("/check")
|
||||
def run_quality_check(db: Session = Depends(get_db)):
|
||||
"""扫描全部KPI,生成数据质量报告"""
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
issues = []
|
||||
current_period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
for kpi in kpis:
|
||||
# 获取最近12个月的值
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).limit(12).all()
|
||||
|
||||
# 1. 检查数据缺失
|
||||
if not values:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "missing_data",
|
||||
"severity": "critical",
|
||||
"detail": {"missing_months": 12, "latest_period": None, "total_values": 0},
|
||||
"suggestion": "请初始化KPI数据,建议导入至少3个月历史数据",
|
||||
})
|
||||
continue
|
||||
|
||||
latest_val = values[0]
|
||||
latest_period = latest_val.period
|
||||
|
||||
# 计算缺失月数
|
||||
if latest_period:
|
||||
try:
|
||||
lp_parts = latest_period.split("-")
|
||||
lp_date = datetime(int(lp_parts[0]), int(lp_parts[1]), 1)
|
||||
now_date = datetime.now().replace(day=1)
|
||||
missing_months = max(0, (now_date.year - lp_date.year) * 12 + (now_date.month - lp_date.month) - 1)
|
||||
if missing_months > 1:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "missing_data",
|
||||
"severity": "warning" if missing_months <= 3 else "critical",
|
||||
"detail": {"missing_months": missing_months, "latest_period": latest_period, "total_values": len(values)},
|
||||
"suggestion": f"数据缺失{missing_months}个月,建议从ERP系统同步或手动补录",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 检查环比骤变(需要至少2个月的值)
|
||||
if len(values) >= 2 and latest_val.actual_value:
|
||||
prev_val = values[1].actual_value
|
||||
if prev_val and prev_val != 0:
|
||||
change_pct = abs((latest_val.actual_value - prev_val) / prev_val * 100)
|
||||
if change_pct > 50:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "abnormal_change",
|
||||
"severity": "warning" if change_pct <= 100 else "critical",
|
||||
"detail": {
|
||||
"change_pct": round(change_pct, 1),
|
||||
"current_value": latest_val.actual_value,
|
||||
"previous_value": prev_val,
|
||||
"current_period": latest_val.period,
|
||||
"previous_period": values[1].period,
|
||||
},
|
||||
"suggestion": f"环比变化{round(change_pct,1)}%,建议核实数据是否录入错误",
|
||||
})
|
||||
|
||||
# 3. 检查连续3期持平
|
||||
if len(values) >= 3:
|
||||
last_3 = [v.actual_value for v in values[:3] if v.actual_value is not None]
|
||||
if len(last_3) >= 3 and len(set(last_3)) == 1:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "flat_data",
|
||||
"severity": "warning",
|
||||
"detail": {"flat_value": last_3[0], "periods": [v.period for v in values[:3]]},
|
||||
"suggestion": "连续3期数据完全相同,请确认数据源是否正常更新",
|
||||
})
|
||||
|
||||
# 4. 检查值异常(偏离历史均值超过3倍标准差)
|
||||
if len(values) >= 4 and latest_val.actual_value:
|
||||
hist_vals = [v.actual_value for v in values[1:] if v.actual_value is not None]
|
||||
if len(hist_vals) >= 3:
|
||||
mean_val = sum(hist_vals) / len(hist_vals)
|
||||
variance = sum((v - mean_val) ** 2 for v in hist_vals) / len(hist_vals)
|
||||
stddev = variance ** 0.5 if variance > 0 else mean_val * 0.1
|
||||
if stddev > 0 and abs(latest_val.actual_value - mean_val) > 3 * stddev:
|
||||
issues.append({
|
||||
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
|
||||
"check_type": "value_outlier",
|
||||
"severity": "warning",
|
||||
"detail": {
|
||||
"current_value": latest_val.actual_value,
|
||||
"mean": round(mean_val, 2),
|
||||
"stddev": round(stddev, 2),
|
||||
"z_score": round(abs(latest_val.actual_value - mean_val) / stddev, 2),
|
||||
},
|
||||
"suggestion": "当前值偏离历史均值超过3倍标准差,建议核实",
|
||||
})
|
||||
|
||||
# 写入质量日志
|
||||
created_count = 0
|
||||
for issue in issues:
|
||||
existing = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.kpi_id == issue["kpi_id"],
|
||||
KpiDataQualityLog.check_type == issue["check_type"],
|
||||
KpiDataQualityLog.status == "open",
|
||||
).first()
|
||||
if not existing:
|
||||
log = KpiDataQualityLog(
|
||||
kpi_id=issue["kpi_id"],
|
||||
check_type=issue["check_type"],
|
||||
severity=issue["severity"],
|
||||
detail=issue["detail"],
|
||||
suggestion=issue["suggestion"],
|
||||
status="open",
|
||||
)
|
||||
db.add(log)
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"total_kpis": len(kpis),
|
||||
"issues_found": len(issues),
|
||||
"new_logs": created_count,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 质量日志CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/logs")
|
||||
def list_quality_logs(
|
||||
kpi_id: Optional[int] = None,
|
||||
severity: Optional[str] = None,
|
||||
check_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取数据质量日志"""
|
||||
query = db.query(KpiDataQualityLog)
|
||||
if kpi_id:
|
||||
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
|
||||
if severity:
|
||||
query = query.filter(KpiDataQualityLog.severity == severity)
|
||||
if check_type:
|
||||
query = query.filter(KpiDataQualityLog.check_type == check_type)
|
||||
if status:
|
||||
query = query.filter(KpiDataQualityLog.status == status)
|
||||
|
||||
logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all()
|
||||
result = []
|
||||
for log in logs:
|
||||
d = _log_to_dict(log)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == log.kpi_id).first()
|
||||
if kpi:
|
||||
d["kpi_code"] = kpi.kpi_code
|
||||
d["kpi_name"] = kpi.kpi_name
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""更新质量日志(解决/忽略)"""
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
if not log:
|
||||
raise HTTPException(404, "日志不存在")
|
||||
if "status" in data:
|
||||
log.status = data["status"]
|
||||
if data["status"] == "resolved":
|
||||
log.resolved_at = datetime.now()
|
||||
if "suggestion" in data:
|
||||
log.suggestion = data["suggestion"]
|
||||
db.commit()
|
||||
return _log_to_dict(log)
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
|
||||
if log:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据质量看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def quality_stats(db: Session = Depends(get_db)):
|
||||
"""数据质量统计"""
|
||||
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
|
||||
total_logs = db.query(KpiDataQualityLog).count()
|
||||
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
|
||||
|
||||
# 按严重程度统计
|
||||
severity_counts = {}
|
||||
for s in ("info", "warning", "critical"):
|
||||
cnt = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.severity == s,
|
||||
KpiDataQualityLog.status == "open",
|
||||
).count()
|
||||
if cnt:
|
||||
severity_counts[s] = cnt
|
||||
|
||||
# 按检查类型统计
|
||||
type_counts = {}
|
||||
for t in ("abnormal_change", "flat_data", "missing_data", "value_outlier"):
|
||||
cnt = db.query(KpiDataQualityLog).filter(
|
||||
KpiDataQualityLog.check_type == t,
|
||||
KpiDataQualityLog.status == "open",
|
||||
).count()
|
||||
if cnt:
|
||||
type_counts[t] = cnt
|
||||
|
||||
return {
|
||||
"total_kpis": total_kpis,
|
||||
"total_logs": total_logs,
|
||||
"open_logs": open_logs,
|
||||
"severity_counts": severity_counts,
|
||||
"type_counts": type_counts,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
|
||||
logger = logging.getLogger("kpi-causality")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def _to_dict(obj):
|
||||
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/full-network")
|
||||
def get_full_network(db: Session = Depends(get_db)):
|
||||
"""获取全局因果网络数据(用于力导向图)"""
|
||||
edges = db.query(KPICausality).all()
|
||||
node_ids = set()
|
||||
edge_list = []
|
||||
for e in edges:
|
||||
node_ids.add(e.source_kpi_id)
|
||||
node_ids.add(e.target_kpi_id)
|
||||
edge_list.append({
|
||||
"source": e.source_kpi_id,
|
||||
"target": e.target_kpi_id,
|
||||
"strength": e.strength,
|
||||
"direction": e.direction,
|
||||
"lag_months": e.lag_months,
|
||||
})
|
||||
|
||||
# 获取所有节点信息
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
|
||||
node_map = {k.id: {
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "category": k.category,
|
||||
} for k in kpis}
|
||||
|
||||
nodes = []
|
||||
for nid in node_ids:
|
||||
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
|
||||
nodes.append(info)
|
||||
|
||||
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}/network")
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 上游(指向当前KPI的因果)
|
||||
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
|
||||
upstream_list = []
|
||||
for c in upstream:
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
if src:
|
||||
upstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
})
|
||||
|
||||
# 下游(当前KPI指向的因果)
|
||||
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
|
||||
downstream_list = []
|
||||
for c in downstream:
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
if tgt:
|
||||
downstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
})
|
||||
|
||||
return {
|
||||
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
|
||||
"upstream": upstream_list,
|
||||
"downstream": downstream_list,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/simulate")
|
||||
def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
|
||||
Body: { kpi_id: int, new_value: float, period: str }
|
||||
"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
new_value = data.get("new_value")
|
||||
period = data.get("period")
|
||||
|
||||
if not kpi_id or new_value is None:
|
||||
raise HTTPException(400, "必须指定kpi_id和new_value")
|
||||
|
||||
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not source_kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取当前值
|
||||
current_value = None
|
||||
query_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
query_values = query_values.filter(KPIValue.period == period)
|
||||
latest = query_values.order_by(KPIValue.period.desc()).first()
|
||||
if latest:
|
||||
current_value = latest.actual_value
|
||||
|
||||
previous_value = current_value or new_value
|
||||
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
|
||||
|
||||
# BFS遍历下游因果链
|
||||
visited = set()
|
||||
impacts = []
|
||||
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
|
||||
|
||||
while queue:
|
||||
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
|
||||
if current_kpi_id in visited:
|
||||
continue
|
||||
visited.add(current_kpi_id)
|
||||
|
||||
# 查找从current_kpi_id出发的下游因果链
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == current_kpi_id
|
||||
).all()
|
||||
|
||||
for edge in downstream:
|
||||
target_id = edge.target_kpi_id
|
||||
if target_id in visited:
|
||||
continue
|
||||
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not target_kpi:
|
||||
continue
|
||||
|
||||
# 计算影响: 变化率 × 强度 × 方向
|
||||
edge_strength = edge.strength or 0.5
|
||||
direction_factor = 1.0 if edge.direction == "positive" else -1.0
|
||||
propagated_change = current_change * edge_strength * direction_factor
|
||||
|
||||
# 获取当前值
|
||||
tgt_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == target_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
predicted_value = None
|
||||
if tgt_val and tgt_val.actual_value:
|
||||
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
|
||||
|
||||
impacts.append({
|
||||
"kpi_id": target_id,
|
||||
"kpi_code": target_kpi.kpi_code,
|
||||
"kpi_name": target_kpi.kpi_name,
|
||||
"dimension": target_kpi.dimension,
|
||||
"current_value": tgt_val.actual_value if tgt_val else None,
|
||||
"predicted_value": predicted_value,
|
||||
"change_pct": round(propagated_change, 2),
|
||||
"strength": edge_strength,
|
||||
"direction": edge.direction,
|
||||
"lag_months": edge.lag_months,
|
||||
"depth": depth + 1,
|
||||
"path_strength": round(cum_strength * edge_strength, 3),
|
||||
})
|
||||
|
||||
# 继续遍历下游
|
||||
new_cum = cum_strength * edge_strength
|
||||
if new_cum > 0.05 and depth < 5:
|
||||
queue.append((target_id, propagated_change, depth + 1, new_cum))
|
||||
|
||||
return {
|
||||
"source": {
|
||||
"kpi_id": source_kpi.id,
|
||||
"kpi_code": source_kpi.kpi_code,
|
||||
"kpi_name": source_kpi.kpi_name,
|
||||
"current_value": current_value,
|
||||
"new_value": new_value,
|
||||
"change_pct": round(change_pct, 2),
|
||||
},
|
||||
"impacts": impacts,
|
||||
"total_impacted": len(impacts),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CRUD (动态路径)
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_causalities(
|
||||
source_kpi_id: Optional[int] = None,
|
||||
target_kpi_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取因果链列表"""
|
||||
query = db.query(KPICausality)
|
||||
if source_kpi_id:
|
||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||
if target_kpi_id:
|
||||
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
result = []
|
||||
for c in items:
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source_kpi_code"] = src.kpi_code if src else None
|
||||
d["source_kpi_name"] = src.kpi_name if src else None
|
||||
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{causality_id}")
|
||||
def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
|
||||
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
|
||||
return d
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""创建因果链"""
|
||||
source_id = data.get("source_kpi_id")
|
||||
target_id = data.get("target_kpi_id")
|
||||
if not source_id or not target_id:
|
||||
raise HTTPException(400, "必须指定源KPI和目标KPI")
|
||||
if source_id == target_id:
|
||||
raise HTTPException(400, "源和目标不能相同")
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not src or not tgt:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == source_id,
|
||||
KPICausality.target_kpi_id == target_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
|
||||
|
||||
c = KPICausality(
|
||||
source_kpi_id=source_id,
|
||||
target_kpi_id=target_id,
|
||||
strength=data.get("strength", 0.5),
|
||||
lag_months=data.get("lag_months", 1),
|
||||
formula=data.get("formula"),
|
||||
direction=data.get("direction", "positive"),
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code}"))
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}")
|
||||
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
for field in ("strength", "lag_months", "formula", "direction"):
|
||||
if field in data:
|
||||
setattr(c, field, data[field])
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.delete("/{causality_id}")
|
||||
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if c:
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
+11
-13
@@ -19,9 +19,9 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
"icon": "💰",
|
||||
"color": "#F56C6C",
|
||||
"objectives": [
|
||||
{"name": "营收目标", "kpis": ["F_REVENUE_001"]},
|
||||
{"name": "净利润率", "kpis": ["F_PROFIT_001"]},
|
||||
{"name": "现金流", "kpis": ["F_CASH_001"]},
|
||||
{"name": "营收目标", "kpis": ["F_REVENUE"]},
|
||||
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
|
||||
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -30,9 +30,9 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
"icon": "👥",
|
||||
"color": "#409EFF",
|
||||
"objectives": [
|
||||
{"name": "客户满意度", "kpis": ["C_CUST_001"]},
|
||||
{"name": "市场份额", "kpis": ["C_CUST_002"]},
|
||||
{"name": "客户保留率", "kpis": ["C_CUST_003"]},
|
||||
{"name": "客户满意度", "kpis": ["C_SATISFACTION"]},
|
||||
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
|
||||
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -41,8 +41,8 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
"icon": "⚙️",
|
||||
"color": "#67C23A",
|
||||
"objectives": [
|
||||
{"name": "运营效率", "kpis": ["P_PROC_001"]},
|
||||
{"name": "质量合格率", "kpis": ["P_PROC_002"]},
|
||||
{"name": "运营效率", "kpis": ["P_DELIVERY"]},
|
||||
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -51,8 +51,8 @@ STRATEGIC_MAP_TEMPLATE = [
|
||||
"icon": "📚",
|
||||
"color": "#E6A23C",
|
||||
"objectives": [
|
||||
{"name": "关键岗位胜任度", "kpis": ["L_TALENT_001"]},
|
||||
{"name": "培训完成率", "kpis": ["L_TALENT_002"]},
|
||||
{"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]},
|
||||
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -171,11 +171,9 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
if from_id == to_id:
|
||||
raise HTTPException(400, "不能自身连线")
|
||||
|
||||
# 校验: 维度不能相同 (learning-0 和 process-0 的维度不同)
|
||||
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
|
||||
from_dim = from_id.rsplit("-", 1)[0]
|
||||
to_dim = to_id.rsplit("-", 1)[0]
|
||||
if from_dim == to_dim:
|
||||
raise HTTPException(400, "同维度内不能连线")
|
||||
|
||||
conns = _get_connections(m)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user