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.
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)
|
||||
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles
|
||||
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -59,6 +59,9 @@ app.include_router(customer_dashboard.router)
|
||||
app.include_router(deviation_push.router)
|
||||
app.include_router(budget_generate.router)
|
||||
app.include_router(knowledge_articles.router)
|
||||
app.include_router(kpi_causality.router)
|
||||
app.include_router(data_quality.router)
|
||||
app.include_router(bi_reports.router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -214,6 +214,61 @@ class MapObjective(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class KPICausality(Base):
|
||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||
__tablename__ = "kpi_causality"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
|
||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class KpiDataQualityLog(Base):
|
||||
"""数据质量监控日志"""
|
||||
__tablename__ = "kpi_data_quality_log"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||
check_type = Column(String(30), nullable=False, comment="abnormal_change/flat_data/missing_data/value_outlier")
|
||||
severity = Column(String(20), default="warning", comment="info/warning/critical")
|
||||
detail = Column(JSON, nullable=True, comment="检测详情")
|
||||
suggestion = Column(String(500), nullable=True, comment="建议操作")
|
||||
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class BiReportTemplate(Base):
|
||||
"""BI报表模板"""
|
||||
__tablename__ = "bi_report_templates"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, comment="模板名称")
|
||||
report_type = Column(String(50), nullable=False, comment="overview/trend/comparison/topn/causality")
|
||||
config = Column(JSON, nullable=False, comment="报表配置")
|
||||
is_system = Column(Integer, default=0, comment="系统预置模板")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class BiReport(Base):
|
||||
"""用户保存的BI报表"""
|
||||
__tablename__ = "bi_reports"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("bi_report_templates.id"), nullable=True)
|
||||
name = Column(String(200), nullable=False, comment="报表名称")
|
||||
config = Column(JSON, nullable=False, comment="报表配置(行/列/值)")
|
||||
chart_type = Column(String(50), default="auto", comment="图表类型")
|
||||
is_shared = Column(Integer, default=0, comment="是否分享")
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# 兼容性: P2开发新增的模板API需要的模型
|
||||
# KPIDefinition 已存在,KPITemplate映射到同一定义
|
||||
KPITemplate = KPIDefinition
|
||||
|
||||
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.
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# 每日ERP数据同步 — 凌晨3点执行
|
||||
# 由Hermes cron调度
|
||||
# P1扩展: 增加CRM模块和产模块同步
|
||||
source /root/cma-management/backend/venv/bin/activate
|
||||
cd /root/cma-management/backend
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
log "=" 60
|
||||
log "ERP每日同步开始"
|
||||
log "=" 60
|
||||
|
||||
# 1. 总账模块同步(原有)
|
||||
log "--- 总账模块同步 ---"
|
||||
python3 -c "
|
||||
from scripts.erp_sync import run_sync
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
run_sync(dry_run=False, use_api=False)
|
||||
" >> /root/cma-management/backend/logs/erp_sync_daily.log 2>&1
|
||||
|
||||
# 2. CRM模块同步(P1新增)
|
||||
log "--- CRM模块同步 ---"
|
||||
python3 -c "
|
||||
from scripts.erp_sync import run_sync
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
run_sync(dry_run=False, kpi_codes=['C_RETENTION_RATE', 'C_NEW_CLIENTS'], use_api=False)
|
||||
" >> /root/cma-management/backend/logs/erp_sync_crm.log 2>&1
|
||||
|
||||
# 3. 生产模块同步(P1新增)
|
||||
log "--- 生产模块同步 ---"
|
||||
python3 -c "
|
||||
from scripts.erp_sync import run_sync
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
run_sync(dry_run=False, kpi_codes=['F_QUALITY_RATE', 'F_REWORK_RATE'], use_api=False)
|
||||
" >> /root/cma-management/backend/logs/erp_sync_production.log 2>&1
|
||||
|
||||
log "=" 60
|
||||
log "ERP每日同步完成"
|
||||
log "=" 60
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
ERP数据源配置首批 — 任务8
|
||||
配置data_source_config + 同步端点 + 定时任务
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
# 首批8个ERP-KPI的数据源配置
|
||||
ERP_SOURCES = [
|
||||
{
|
||||
"name": "ERP总账-营业收入",
|
||||
"kpi_codes": ["F_REVENUE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/stats/monthly",
|
||||
"query_sql": "SELECT COALESCE(SUM(SumMoney), 0) as value FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP总账-毛利率",
|
||||
"kpi_codes": ["F_GROSS_MARGIN", "F_PROFIT_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/stats/gross-profit",
|
||||
"query_sql": "SELECT CASE WHEN SUM(SumMoney) > 0 THEN ROUND((SUM(SumMoney)-COALESCE(SUM(SumCostMoney),0))/SUM(SumMoney)*100,2) ELSE 0 END as value FROM MasterBill WHERE BillType=1 AND BillState>=3 AND Period=:period",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP总账-净利润",
|
||||
"kpi_codes": ["F_NET_PROFIT", "F_NET_PROFIT_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/stats/monthly",
|
||||
"query_sql": "SELECT COALESCE(SUM(SumMoney),0)-COALESCE(SUM(SumCostMoney),0)-COALESCE(SUM(SumExpense),0) as value FROM MasterBill WHERE BillType IN (1,2,3) AND BillState>=3 AND Period=:period",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP总账-费用率",
|
||||
"kpi_codes": ["F_COST_RATIO"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/stats/monthly",
|
||||
"query_sql": "SELECT CASE WHEN SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END) > 0 THEN ROUND((COALESCE(SUM(CASE WHEN BillType=2 THEN SumMoney ELSE 0 END),0)+COALESCE(SUM(CASE WHEN BillType=3 THEN SumMoney ELSE 0 END),0))/SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END)*100,2) ELSE 0 END as value FROM MasterBill WHERE BillState>=3 AND Period=:period",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP总账-经营性现金流",
|
||||
"kpi_codes": ["F_OP_CFLOW", "F_CASH_FLOW"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/cashflow",
|
||||
"query_sql": "SELECT COALESCE(SUM(CashIn),0)-COALESCE(SUM(CashOut),0) as value FROM CashFlow WHERE Period=:period",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP应收-周转天数",
|
||||
"kpi_codes": ["F_AR_DAYS", "F_AR_TURNOVER"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/ar/aging",
|
||||
"query_sql": "SELECT CASE WHEN total_sales > 0 THEN ROUND(AVG_receivable/total_sales*365,0) ELSE 0 END as value FROM (SELECT SUM(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END) as total_sales, AVG(CASE WHEN BillType=1 THEN SumMoney ELSE 0 END) as AVG_receivable FROM MasterBill WHERE BillState>=3 AND Period<=:period) t",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP交付-及时率",
|
||||
"kpi_codes": ["P_DELIVERY", "P_DELIVERY_ON_TIME"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/delivery/rate",
|
||||
"query_sql": "SELECT CASE WHEN total_orders > 0 THEN ROUND(on_time_orders/total_orders*100,2) ELSE 0 END as value FROM (SELECT COUNT(*) as total_orders, SUM(CASE WHEN ActualDelivery<=PlanDelivery THEN 1 ELSE 0 END) as on_time_orders FROM OrderDelivery WHERE Period=:period) t",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP质量-缺陷率",
|
||||
"kpi_codes": ["P_BUG_RATE", "P_DEFECT_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://127.0.0.1:8300/api/v1/quality/defect",
|
||||
"query_sql": "SELECT CASE WHEN total_qty > 0 THEN ROUND(defect_qty/total_qty*100,2) ELSE 0 END as value FROM (SELECT COUNT(*) as total_qty, SUM(CASE WHEN QualityStatus='NG' THEN 1 ELSE 0 END) as defect_qty FROM QualityInspection WHERE Period=:period) t",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
]
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now():%H:%M:%S}] {msg}")
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("=" * 70)
|
||||
log("配置ERP数据源 (data_source_config)")
|
||||
print("=" * 70)
|
||||
|
||||
existing = conn.execute(text("SELECT COUNT(*) FROM data_source_config")).scalar()
|
||||
log(f"当前已有 {existing} 条数据源配置")
|
||||
|
||||
created = 0
|
||||
for src in ERP_SOURCES:
|
||||
exists = conn.execute(
|
||||
text("SELECT id FROM data_source_config WHERE name=:name"),
|
||||
{"name": src["name"]}
|
||||
).fetchone()
|
||||
if exists:
|
||||
log(f" ⏭️ 已存在: {src['name']} (id={exists[0]})")
|
||||
continue
|
||||
|
||||
conn.execute(text("""
|
||||
INSERT INTO data_source_config
|
||||
(name, source_type, api_endpoint, query_sql, sync_type, status, created_at)
|
||||
VALUES (:name, :source_type, :api_endpoint, :query_sql, :sync_type, 'active', NOW())
|
||||
"""), {
|
||||
"name": src["name"],
|
||||
"source_type": src["source_type"],
|
||||
"api_endpoint": src["api_endpoint"],
|
||||
"query_sql": src["query_sql"],
|
||||
"sync_type": src["sync_type"],
|
||||
})
|
||||
created += 1
|
||||
log(f" ✅ 新增: {src['name']} — 关联KPI: {', '.join(src['kpi_codes'])}")
|
||||
|
||||
conn.commit()
|
||||
log(f"\n✅ 数据源配置完成: 新增 {created} 条, 当前共 {existing + created} 条")
|
||||
|
||||
# 打印所有数据源
|
||||
rows = conn.execute(text("SELECT id, name, source_type, sync_type, status FROM data_source_config ORDER BY id")).fetchall()
|
||||
print("\n数据源清单:")
|
||||
for r in rows:
|
||||
print(f" [{r[0]}] {r[1]:30s} type={r[2]:10s} sync={r[3]:10s} status={r[4]}")
|
||||
@@ -0,0 +1,148 @@
|
||||
""""
|
||||
ERP数据源扩展 — 任务5: CRM+生产模块对接
|
||||
执行: cd /root/cma-management/backend && python3 scripts/erp_p1_crm_prod.py
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now():%H:%M:%S}] {msg}")
|
||||
|
||||
# CRM + 生产模块数据源配置
|
||||
NEW_SOURCES = [
|
||||
# ─── CRM模块 ───
|
||||
{
|
||||
"name": "ERP-CRM-客户保留率",
|
||||
"kpi_codes": ["C_RETENTION_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://erp-api.sxbh.ltd/api/v1/crm/retention-rate",
|
||||
"query_sql": """SELECT CASE WHEN total_customers > 0
|
||||
THEN ROUND(renew_customers/total_customers*100, 2) ELSE 0 END as value
|
||||
FROM (SELECT COUNT(*) as total_customers,
|
||||
SUM(CASE WHEN DATEDIFF(day, last_order_date, GETDATE()) <= 365 THEN 1 ELSE 0 END) as renew_customers
|
||||
FROM Units WHERE UnitType='Customer') t""",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP-CRM-新客户数",
|
||||
"kpi_codes": ["C_NEW_CLIENTS"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://erp-api.sxbh.ltd/api/v1/crm/new-customers",
|
||||
"query_sql": "SELECT COUNT(*) as value FROM Units WHERE UnitType='Customer' AND DATEDIFF(day, CreateDate, GETDATE()) <= 30",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
# ─── 生产模块 ───
|
||||
{
|
||||
"name": "ERP-生产-产品合格率",
|
||||
"kpi_codes": ["F_QUALITY_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://erp-api.sxbh.ltd/api/v1/production/quality-rate",
|
||||
"query_sql": """SELECT CASE WHEN total_qty > 0
|
||||
THEN ROUND(qualified_qty/total_qty*100, 2) ELSE 0 END as value
|
||||
FROM (SELECT COUNT(*) as total_qty,
|
||||
SUM(CASE WHEN QualityStatus='OK' THEN 1 ELSE 0 END) as qualified_qty
|
||||
FROM QualityInspection WHERE Period=:period) t""",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
{
|
||||
"name": "ERP-生产-返工率",
|
||||
"kpi_codes": ["F_REWORK_RATE"],
|
||||
"source_type": "erp",
|
||||
"api_endpoint": "http://erp-api.sxbh.ltd/api/v1/production/rework-rate",
|
||||
"query_sql": """SELECT CASE WHEN total_qty > 0
|
||||
THEN ROUND(rework_qty/total_qty*100, 2) ELSE 0 END as value
|
||||
FROM (SELECT COUNT(*) as total_qty,
|
||||
SUM(CASE WHEN ReworkStatus='Rework' THEN 1 ELSE 0 END) as rework_qty
|
||||
FROM ProductionOrder WHERE Period=:period) t""",
|
||||
"sync_type": "daily",
|
||||
},
|
||||
]
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("=" * 70)
|
||||
log("开始ERP数据源扩展 — 任务5: CRM+生产模块")
|
||||
print("=" * 70)
|
||||
|
||||
created = 0
|
||||
for src in NEW_SOURCES:
|
||||
exists = conn.execute(
|
||||
text("SELECT id FROM data_source_config WHERE name=:name"),
|
||||
{"name": src["name"]}
|
||||
).fetchone()
|
||||
if exists:
|
||||
log(f" ⏭️ 已存在: {src['name']} (id={exists[0]})")
|
||||
continue
|
||||
|
||||
conn.execute(text("""
|
||||
INSERT INTO data_source_config
|
||||
(name, source_type, api_endpoint, query_sql, sync_type, status, created_at)
|
||||
VALUES (:name, :source_type, :api_endpoint, :query_sql, :sync_type, 'active', NOW())
|
||||
"""), {
|
||||
"name": src["name"],
|
||||
"source_type": src["source_type"],
|
||||
"api_endpoint": src["api_endpoint"],
|
||||
"query_sql": src["query_sql"],
|
||||
"sync_type": src["sync_type"],
|
||||
})
|
||||
created += 1
|
||||
log(f" ✅ 新增: {src['name']} — 关联KPI: {', '.join(src['kpi_codes'])}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 打印所有数据源
|
||||
rows = conn.execute(text(
|
||||
"SELECT id, name, source_type, sync_type, status FROM data_source_config ORDER BY id"
|
||||
)).fetchall()
|
||||
print("\n数据源清单:")
|
||||
for r in rows:
|
||||
print(f" [{r[0]}] {r[1]:35s} type={r[2]:10s} sync={r[3]:10s} status={r[4]}")
|
||||
log(f"\n✅ CRM+生产模块数据源配置完成: 新增 {created} 条, 共 {len(rows)} 条")
|
||||
|
||||
# 更新KPI的data_source_config字段(存储关联的数据源ID)
|
||||
log("\n更新KPI的data_source_config字段...")
|
||||
for src in NEW_SOURCES:
|
||||
src_row = conn.execute(
|
||||
text("SELECT id FROM data_source_config WHERE name=:name"),
|
||||
{"name": src["name"]}
|
||||
).fetchone()
|
||||
if not src_row:
|
||||
continue
|
||||
for kpi_code in src["kpi_codes"]:
|
||||
kpi = conn.execute(
|
||||
text("SELECT id, data_source_config FROM kpi_definitions WHERE kpi_code=:code AND status='active'"),
|
||||
{"code": kpi_code}
|
||||
).fetchone()
|
||||
if kpi:
|
||||
existing_config = kpi[1] or {}
|
||||
if isinstance(existing_config, str):
|
||||
import json
|
||||
try:
|
||||
existing_config = json.loads(existing_config)
|
||||
except:
|
||||
existing_config = {}
|
||||
# 确保是dict
|
||||
if not isinstance(existing_config, dict):
|
||||
existing_config = {"source_ids": []}
|
||||
if "source_ids" not in existing_config:
|
||||
existing_config["source_ids"] = []
|
||||
if src_row[0] not in existing_config["source_ids"]:
|
||||
existing_config["source_ids"].append(src_row[0])
|
||||
import json
|
||||
conn.execute(
|
||||
text("UPDATE kpi_definitions SET data_source_config=:config WHERE id=:id"),
|
||||
{"config": json.dumps(existing_config, ensure_ascii=False), "id": kpi[0]}
|
||||
)
|
||||
log(f" ✅ KPI {kpi_code}: data_source_config 已更新")
|
||||
|
||||
conn.commit()
|
||||
log("\n✅ 任务5: CRM+生产模块对接完成")
|
||||
print("=" * 70)
|
||||
@@ -110,6 +110,12 @@ def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float:
|
||||
"CUSTOMER_COUNT": f"{ERP_API_BASE}/stats/monthly?year={period_year}",
|
||||
"SALES_PROFIT_RATE": f"{ERP_API_BASE}/stats/gross-profit?year={period_year}&month={period_month}",
|
||||
"TOP5_CUSTOMER_RATIO": f"{ERP_API_BASE}/stats/customer-top?year={period_year}&limit=5",
|
||||
# P1: CRM模块
|
||||
"C_RETENTION_RATE": f"{ERP_API_BASE}/crm/retention-rate?year={period_year}&month={period_month}",
|
||||
"C_NEW_CLIENTS": f"{ERP_API_BASE}/crm/new-customers?year={period_year}&month={period_month}",
|
||||
# P1: 生产模块
|
||||
"F_QUALITY_RATE": f"{ERP_API_BASE}/production/quality-rate?year={period_year}&month={period_month}",
|
||||
"F_REWORK_RATE": f"{ERP_API_BASE}/production/rework-rate?year={period_year}&month={period_month}",
|
||||
}
|
||||
|
||||
headers = {"X-API-Key": ERP_API_KEY, "User-Agent": "CMA-ERP-SYNC/1.0"}
|
||||
@@ -159,6 +165,22 @@ def fetch_via_api(kpi: KPIDefinition, parsed: dict, period: str) -> float:
|
||||
return round(top5_total / total_amount * 100, 2)
|
||||
return 0
|
||||
|
||||
# P1: CRM模块 — 客户保留率
|
||||
elif kpi_code == "C_RETENTION_RATE":
|
||||
return float(data.get("retention_rate", data.get("value", 0)))
|
||||
|
||||
# P1: CRM模块 — 新客户数
|
||||
elif kpi_code == "C_NEW_CLIENTS":
|
||||
return float(data.get("new_customers", data.get("value", 0)))
|
||||
|
||||
# P1: 生产模块 — 产品合格率
|
||||
elif kpi_code == "F_QUALITY_RATE":
|
||||
return float(data.get("quality_rate", data.get("value", 0)))
|
||||
|
||||
# P1: 生产模块 — 返工率
|
||||
elif kpi_code == "F_REWORK_RATE":
|
||||
return float(data.get("rework_rate", data.get("value", 0)))
|
||||
|
||||
raise ValueError(f"未实现的API映射: {kpi_code}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
KPI字典库优化P0阶段 — 批量脚本
|
||||
涵盖任务1-6: 激活模板KPI + 新增KPI + 修复员工满意度
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now():%H:%M:%S}] {msg}")
|
||||
|
||||
def insert_kpi(conn, **kw):
|
||||
"""安全插入KPI(检查唯一性)"""
|
||||
code = kw["kpi_code"]
|
||||
exists = conn.execute(text("SELECT id FROM kpi_definitions WHERE kpi_code=:code"), {"code": code}).fetchone()
|
||||
if exists:
|
||||
log(f" ⏭️ 已存在: {code} (id={exists[0]})")
|
||||
return exists[0], False
|
||||
cols = ", ".join(kw.keys())
|
||||
vals = ", ".join(f":{k}" for k in kw.keys())
|
||||
conn.execute(text(f"INSERT INTO kpi_definitions ({cols}) VALUES ({vals})"), kw)
|
||||
new_id = conn.execute(text("SELECT LAST_INSERT_ID()")).scalar()
|
||||
log(f" ✅ 新增: {code} (id={new_id})")
|
||||
return new_id, True
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("=" * 70)
|
||||
log("开始KPI字典库优化P0 — 任务1~6")
|
||||
print("=" * 70)
|
||||
|
||||
# =========================================================
|
||||
# 任务1: 激活4个系统模板KPI
|
||||
# =========================================================
|
||||
log("\n【任务1】激活系统模板KPI")
|
||||
print("-" * 50)
|
||||
|
||||
# 从kpi_templates读取模板数据
|
||||
templates = conn.execute(text(
|
||||
"SELECT id, kpi_code, kpi_name, dimension, category, formula, formula_desc, "
|
||||
"unit, target_value, description FROM kpi_templates WHERE kpi_code IN "
|
||||
"('F_ASSET_TURNOVER', 'F_REVENUE_GROWTH', 'L_EMPLOYEE_TURNOVER', 'L_TECH_COVERAGE')"
|
||||
)).fetchall()
|
||||
|
||||
for t in templates:
|
||||
tid, code, name, dim, cat, formula, fdesc, unit, target, desc = t
|
||||
nid, is_new = insert_kpi(conn,
|
||||
template_id=tid,
|
||||
is_system=1,
|
||||
kpi_code=code,
|
||||
kpi_name=name,
|
||||
dimension=dim,
|
||||
category=cat,
|
||||
formula=formula or "",
|
||||
formula_desc=fdesc or desc or "",
|
||||
unit=unit or "%",
|
||||
target_value=target,
|
||||
data_source_type="manual",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
if is_new:
|
||||
# 设置阈值
|
||||
if code == "F_ASSET_TURNOVER":
|
||||
conn.execute(text("UPDATE kpi_definitions SET threshold_green='>=1.0', threshold_yellow='>=0.8', threshold_red='<0.8' WHERE id=:id"), {"id": nid})
|
||||
elif code == "F_REVENUE_GROWTH":
|
||||
conn.execute(text("UPDATE kpi_definitions SET threshold_green='>=20', threshold_yellow='>=15', threshold_red='<15' WHERE id=:id"), {"id": nid})
|
||||
elif code == "L_EMPLOYEE_TURNOVER":
|
||||
conn.execute(text("UPDATE kpi_definitions SET threshold_green='<=5', threshold_yellow='<=10', threshold_red='>10' WHERE id=:id"), {"id": nid})
|
||||
elif code == "L_TECH_COVERAGE":
|
||||
conn.execute(text("UPDATE kpi_definitions SET threshold_green='>=90', threshold_yellow='>=80', threshold_red='<80' WHERE id=:id"), {"id": nid})
|
||||
# 更新模板使用计数
|
||||
conn.execute(text("UPDATE kpi_templates SET usage_count = IFNULL(usage_count,0)+1 WHERE id=:id"), {"id": tid})
|
||||
log(f" 阈值已配置")
|
||||
|
||||
# =========================================================
|
||||
# 任务2: 新增6个财务KPI
|
||||
# =========================================================
|
||||
log("\n【任务2】新增6个财务KPI")
|
||||
print("-" * 50)
|
||||
|
||||
finance_kpis = [
|
||||
("F_CURRENT_RATIO", "流动比率", "finance", "cash_risk",
|
||||
"流动资产/流动负债", "%", 200.0, ">=200", ">=150", "<150", "erp"),
|
||||
("F_QUICK_RATIO", "速动比率", "finance", "cash_risk",
|
||||
"(流动资产-存货)/流动负债", "%", 100.0, ">=100", ">=80", "<80", "erp"),
|
||||
("F_INV_DAYS", "存货周转天数", "finance", "asset_efficiency",
|
||||
"365/存货周转率", "天", 45.0, "<=30", "<=45", ">45", "erp"),
|
||||
("F_ROI", "总资产报酬率(ROI)", "finance", "profitability",
|
||||
"净利润/平均总资产*100", "%", 8.0, ">=12", ">=8", "<8", "erp"),
|
||||
("F_QUALITY_RATE", "产品合格率", "finance", "delivery_quality",
|
||||
"正品数/总产量*100", "%", 98.0, ">=99", ">=98", "<98", "erp"),
|
||||
("F_REWORK_RATE", "返工率", "finance", "delivery_quality",
|
||||
"返工工时/总工时*100", "%", 5.0, "<=3", "<=5", ">5", "erp"),
|
||||
]
|
||||
|
||||
for code, name, dim, cat, formula, unit, target, tg, ty, tr, src in finance_kpis:
|
||||
nid, is_new = insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code=code,
|
||||
kpi_name=name,
|
||||
dimension=dim,
|
||||
category=cat,
|
||||
formula=formula,
|
||||
formula_desc=f"CMA标准{name}指标",
|
||||
unit=unit,
|
||||
target_value=target,
|
||||
threshold_green=tg,
|
||||
threshold_yellow=ty,
|
||||
threshold_red=tr,
|
||||
data_source_type=src,
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务3: 新增3个客户KPI
|
||||
# =========================================================
|
||||
log("\n【任务3】新增3个客户KPI")
|
||||
print("-" * 50)
|
||||
|
||||
# 激活C_CUSTOMER_CONCENTRATION模板
|
||||
t = conn.execute(text(
|
||||
"SELECT id, kpi_code, kpi_name, dimension, category, formula, formula_desc, "
|
||||
"unit, target_value, description FROM kpi_templates WHERE kpi_code='C_CUSTOMER_CONCENTRATION'"
|
||||
)).fetchone()
|
||||
if t:
|
||||
nid, is_new = insert_kpi(conn,
|
||||
template_id=t[0],
|
||||
is_system=1,
|
||||
kpi_code="C_CUST_CONCENTRATION",
|
||||
kpi_name="客户集中度",
|
||||
dimension=t[3],
|
||||
category=t[4],
|
||||
formula=t[5] or "",
|
||||
formula_desc=t[6] or t[9] or "",
|
||||
unit=t[7] or "%",
|
||||
target_value=t[8],
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
threshold_green="<=20",
|
||||
threshold_yellow="<=30",
|
||||
threshold_red=">30",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
if is_new:
|
||||
conn.execute(text("UPDATE kpi_templates SET usage_count = IFNULL(usage_count,0)+1 WHERE id=:id"), {"id": t[0]})
|
||||
|
||||
# C_RETENTION_RATE
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="C_RETENTION_RATE",
|
||||
kpi_name="客户保留率",
|
||||
dimension="customer",
|
||||
category="customer_scale",
|
||||
formula="续约客户数/总客户数*100",
|
||||
formula_desc="CMA标准客户保留率指标,反映客户粘性",
|
||||
unit="%",
|
||||
target_value=85.0,
|
||||
threshold_green=">=90",
|
||||
threshold_yellow=">=85",
|
||||
threshold_red="<85",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# C_CAC — 注意C_CAC可能已存在(reset_kpi_dict中有定义),改用C_ACQUISITION_COST
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="C_ACQUISITION_COST",
|
||||
kpi_name="获客成本(CAC)",
|
||||
dimension="customer",
|
||||
category="customer_scale",
|
||||
formula="营销费用/新客户数",
|
||||
formula_desc="CMA标准客户获取成本指标",
|
||||
unit="元",
|
||||
target_value=None,
|
||||
threshold_green="<=500",
|
||||
threshold_yellow="<=800",
|
||||
threshold_red=">800",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务4: 新增2个流程KPI
|
||||
# =========================================================
|
||||
log("\n【任务4】新增2个流程KPI")
|
||||
print("-" * 50)
|
||||
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="P_PASS_RATE",
|
||||
kpi_name="过程合格率",
|
||||
dimension="process",
|
||||
category="delivery_quality",
|
||||
formula="过程合格批次数/总检验批次数*100",
|
||||
formula_desc="流程层关注过程质量合格率,区别于财务层产品合格率",
|
||||
unit="%",
|
||||
target_value=95.0,
|
||||
threshold_green=">=98",
|
||||
threshold_yellow=">=95",
|
||||
threshold_red="<95",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="P_REWORK_RATE",
|
||||
kpi_name="流程返工率",
|
||||
dimension="process",
|
||||
category="delivery_quality",
|
||||
formula="返工批次/总生产批次*100",
|
||||
formula_desc="流程层返工率,体现过程质量控制水平",
|
||||
unit="%",
|
||||
target_value=5.0,
|
||||
threshold_green="<=3",
|
||||
threshold_yellow="<=5",
|
||||
threshold_red=">5",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务5: 新增2个学习KPI
|
||||
# =========================================================
|
||||
log("\n【任务5】新增2个学习KPI")
|
||||
print("-" * 50)
|
||||
|
||||
# 激活L_TRAINING_HOURS模板
|
||||
t = conn.execute(text(
|
||||
"SELECT id, kpi_code, kpi_name, dimension, category, formula, formula_desc, "
|
||||
"unit, target_value, description FROM kpi_templates WHERE kpi_code='L_TRAINING_HOURS'"
|
||||
)).fetchone()
|
||||
if t:
|
||||
nid, is_new = insert_kpi(conn,
|
||||
template_id=t[0],
|
||||
is_system=1,
|
||||
kpi_code="L_TRAINING_HOURS",
|
||||
kpi_name="人均培训时长",
|
||||
dimension=t[3],
|
||||
category=t[4],
|
||||
formula=t[5] or "",
|
||||
formula_desc=t[6] or t[9] or "",
|
||||
unit=t[7] or "小时",
|
||||
target_value=t[8],
|
||||
data_source_type="manual",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
threshold_green=">=40",
|
||||
threshold_yellow=">=20",
|
||||
threshold_red="<20",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
if is_new:
|
||||
conn.execute(text("UPDATE kpi_templates SET usage_count = IFNULL(usage_count,0)+1 WHERE id=:id"), {"id": t[0]})
|
||||
|
||||
# L_COMPETENCY
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="L_COMPETENCY",
|
||||
kpi_name="关键岗位胜任度",
|
||||
dimension="learning",
|
||||
category="talent_pipeline",
|
||||
formula="胜任评估得分≥80分人数/关键岗位总人数*100",
|
||||
formula_desc="CMA标准关键岗位胜任度指标,通过胜任力评估获取",
|
||||
unit="%",
|
||||
target_value=85.0,
|
||||
threshold_green=">=90",
|
||||
threshold_yellow=">=85",
|
||||
threshold_red="<85",
|
||||
data_source_type="manual",
|
||||
frequency="quarterly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务6: 修复员工满意度阈值
|
||||
# =========================================================
|
||||
log("\n【任务6】修复员工满意度阈值")
|
||||
print("-" * 50)
|
||||
|
||||
sat = conn.execute(text("SELECT id FROM kpi_definitions WHERE kpi_code='L_EMPLOYEE_SAT'")).fetchone()
|
||||
if sat:
|
||||
conn.execute(text(
|
||||
"UPDATE kpi_definitions SET threshold_green='>=80', threshold_yellow='>=60', threshold_red='<60' WHERE id=:id"
|
||||
), {"id": sat[0]})
|
||||
log(f" ✅ 员工满意度(id={sat[0]}) 阈值已设置: 绿>=80, 黄>=60, 红<60")
|
||||
else:
|
||||
log(" ⚠️ 员工满意度KPI不存在")
|
||||
|
||||
# =========================================================
|
||||
# 提交事务
|
||||
# =========================================================
|
||||
conn.commit()
|
||||
|
||||
# 统计
|
||||
total = conn.execute(text("SELECT COUNT(*) FROM kpi_definitions WHERE status='active'")).scalar()
|
||||
by_dim = conn.execute(text(
|
||||
"SELECT dimension, COUNT(*) FROM kpi_definitions WHERE status='active' GROUP BY dimension ORDER BY dimension"
|
||||
)).fetchall()
|
||||
no_threshold = conn.execute(text(
|
||||
"SELECT kpi_code, kpi_name FROM kpi_definitions WHERE status='active' AND (threshold_green IS NULL OR threshold_yellow IS NULL OR threshold_red IS NULL)"
|
||||
)).fetchall()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
log(f"✅ 任务1~6完成!KPI字典共 {total} 条")
|
||||
for d, c in by_dim:
|
||||
print(f" {d}: {c} 条")
|
||||
if no_threshold:
|
||||
print(f"\n⚠️ 以下KPI仍缺阈值:")
|
||||
for code, name in no_threshold:
|
||||
print(f" {code}: {name}")
|
||||
else:
|
||||
print("\n✅ 所有KPI均有阈值配置")
|
||||
print("=" * 70)
|
||||
@@ -0,0 +1,256 @@
|
||||
""""
|
||||
KPI字典库优化P1阶段 — Tasks 1-4: 新增9个P1级KPI
|
||||
执行: cd /root/cma-management/backend && python3 scripts/kpi_p1_optimization.py
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
def log(msg):
|
||||
print(f"[{datetime.now():%H:%M:%S}] {msg}")
|
||||
|
||||
def insert_kpi(conn, **kw):
|
||||
code = kw["kpi_code"]
|
||||
exists = conn.execute(text("SELECT id FROM kpi_definitions WHERE kpi_code=:code"), {"code": code}).fetchone()
|
||||
if exists:
|
||||
log(f" ⏭️ 已存在: {code} (id={exists[0]})")
|
||||
return exists[0], False
|
||||
cols = ", ".join(kw.keys())
|
||||
vals = ", ".join(f":{k}" for k in kw.keys())
|
||||
conn.execute(text(f"INSERT INTO kpi_definitions ({cols}) VALUES ({vals})"), kw)
|
||||
new_id = conn.execute(text("SELECT LAST_INSERT_ID()")).scalar()
|
||||
log(f" ✅ 新增: {code} (id={new_id})")
|
||||
return new_id, True
|
||||
|
||||
def activate_template(conn, template_code, target_code, target_name, threshold_green, threshold_yellow, threshold_red, data_source_type="manual", frequency="monthly"):
|
||||
"""从模板激活KPI"""
|
||||
t = conn.execute(text(
|
||||
"SELECT id, kpi_code, kpi_name, dimension, category, formula, formula_desc, "
|
||||
"unit, target_value, description FROM kpi_templates WHERE kpi_code=:code"
|
||||
), {"code": template_code}).fetchone()
|
||||
if not t:
|
||||
log(f" ❌ 模板不存在: {template_code}")
|
||||
return None, False
|
||||
nid, is_new = insert_kpi(conn,
|
||||
template_id=t[0],
|
||||
is_system=1,
|
||||
kpi_code=target_code,
|
||||
kpi_name=target_name or t[2],
|
||||
dimension=t[3],
|
||||
category=t[4],
|
||||
formula=t[5] or "",
|
||||
formula_desc=t[6] or t[9] or "",
|
||||
unit=t[7] or "%",
|
||||
target_value=t[8],
|
||||
data_source_type=data_source_type,
|
||||
frequency=frequency,
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
threshold_green=threshold_green,
|
||||
threshold_yellow=threshold_yellow,
|
||||
threshold_red=threshold_red,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
if is_new:
|
||||
conn.execute(text("UPDATE kpi_templates SET usage_count = IFNULL(usage_count,0)+1 WHERE id=:id"), {"id": t[0]})
|
||||
return nid, is_new
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("=" * 70)
|
||||
log("开始KPI字典库优化P1 — 任务1~4: 新增9个P1级KPI")
|
||||
print("=" * 70)
|
||||
|
||||
# =========================================================
|
||||
# 任务1: 新增财务P1级KPI(3个)
|
||||
# =========================================================
|
||||
log("\n【任务1】新增财务P1级KPI")
|
||||
print("-" * 50)
|
||||
|
||||
finance_kpis = [
|
||||
("F_DEBT_RATIO", "资产负债率", "finance", "cash_risk",
|
||||
"总负债/总资产", "%", 50.0, "<50", "<70", ">=70", "erp"),
|
||||
("F_INTEREST_COVER", "利息保障倍数", "finance", "profitability",
|
||||
"EBIT/利息费用", "倍", 5.0, ">5", ">2", "<=2", "erp"),
|
||||
("F_EVA", "经济增加值(EVA)", "finance", "profitability",
|
||||
"税后净营业利润-资本成本", "元", 0.0, ">0", ">-100000", "<=-100000", "erp"),
|
||||
]
|
||||
|
||||
for code, name, dim, cat, formula, unit, target, tg, ty, tr, src in finance_kpis:
|
||||
nid, is_new = insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code=code,
|
||||
kpi_name=name,
|
||||
dimension=dim,
|
||||
category=cat,
|
||||
formula=formula,
|
||||
formula_desc=f"CMA标准{name}指标",
|
||||
unit=unit,
|
||||
target_value=target,
|
||||
threshold_green=tg,
|
||||
threshold_yellow=ty,
|
||||
threshold_red=tr,
|
||||
data_source_type=src,
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务2: 新增客户P1级KPI(2个)
|
||||
# =========================================================
|
||||
log("\n【任务2】新增客户P1级KPI")
|
||||
print("-" * 50)
|
||||
|
||||
customer_kpis = [
|
||||
("C_MARKET_SHARE", "市场份额", "customer", "customer_scale",
|
||||
"公司收入/行业总收入", "%", 10.0, ">10", ">5", "<=5", "erp"),
|
||||
("C_NPS", "净推荐值(NPS)", "customer", "customer_satisfaction",
|
||||
"NPS评分(-100~100)", "分", 50.0, ">50", ">0", "<=0", "manual"),
|
||||
]
|
||||
|
||||
for code, name, dim, cat, formula, unit, target, tg, ty, tr, src in customer_kpis:
|
||||
nid, is_new = insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code=code,
|
||||
kpi_name=name,
|
||||
dimension=dim,
|
||||
category=cat,
|
||||
formula=formula,
|
||||
formula_desc=f"CMA标准{name}指标",
|
||||
unit=unit,
|
||||
target_value=target,
|
||||
threshold_green=tg,
|
||||
threshold_yellow=ty,
|
||||
threshold_red=tr,
|
||||
data_source_type=src,
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务3: 新增流程P1级KPI(3个)
|
||||
# =========================================================
|
||||
log("\n【任务3】新增流程P1级KPI")
|
||||
print("-" * 50)
|
||||
|
||||
# 3.1 激活P_SUPPLY_CYCLE模板
|
||||
log(" --- 激活模板: P_SUPPLY_CYCLE (供应链响应周期)")
|
||||
activate_template(conn, "P_SUPPLY_CYCLE", "P_SUPPLY_CYCLE", None,
|
||||
"<=7", "<=14", ">14", "erp", "monthly")
|
||||
|
||||
# 3.2 产能利用率
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="P_CAPACITY_UTIL",
|
||||
kpi_name="产能利用率",
|
||||
dimension="process",
|
||||
category="supply_chain",
|
||||
formula="实际产出/理论产能",
|
||||
formula_desc="CMA标准产能利用率指标,反映生产资源利用效率",
|
||||
unit="%",
|
||||
target_value=85.0,
|
||||
threshold_green=">85",
|
||||
threshold_yellow=">70",
|
||||
threshold_red="<=70",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# 3.3 研发投入占比
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="P_RD_RATIO",
|
||||
kpi_name="研发投入占比",
|
||||
dimension="process",
|
||||
category="innovation",
|
||||
formula="研发费用/收入",
|
||||
formula_desc="CMA标准研发投入占比指标,衡量创新投入力度",
|
||||
unit="%",
|
||||
target_value=5.0,
|
||||
threshold_green=">5",
|
||||
threshold_yellow=">2",
|
||||
threshold_red="<=2",
|
||||
data_source_type="erp",
|
||||
frequency="monthly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 任务4: 新增学习P1级KPI(2个)
|
||||
# =========================================================
|
||||
log("\n【任务4】新增学习P1级KPI")
|
||||
print("-" * 50)
|
||||
|
||||
# 4.1 激活L_INNOVATION_COUNT模板
|
||||
log(" --- 激活模板: L_INNOVATION_COUNT (创新提案数量)")
|
||||
activate_template(conn, "L_INNOVATION_COUNT", "L_INNOVATION_COUNT", None,
|
||||
">=12", ">=6", "<6", "manual", "monthly")
|
||||
|
||||
# 4.2 战略认知度
|
||||
insert_kpi(conn,
|
||||
is_system=0,
|
||||
kpi_code="L_STRATEGY_AWARE",
|
||||
kpi_name="战略认知度",
|
||||
dimension="learning",
|
||||
category="employee_engagement",
|
||||
formula="员工战略理解度评分",
|
||||
formula_desc="CMA标准战略认知度指标,通过员工调研获取",
|
||||
unit="分",
|
||||
target_value=80.0,
|
||||
threshold_green=">80",
|
||||
threshold_yellow=">60",
|
||||
threshold_red="<=60",
|
||||
data_source_type="manual",
|
||||
frequency="quarterly",
|
||||
status="active",
|
||||
epic="Epic2",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 提交事务
|
||||
# =========================================================
|
||||
conn.commit()
|
||||
|
||||
# 统计
|
||||
total = conn.execute(text("SELECT COUNT(*) FROM kpi_definitions WHERE status='active'")).scalar()
|
||||
by_dim = conn.execute(text(
|
||||
"SELECT dimension, COUNT(*) FROM kpi_definitions WHERE status='active' GROUP BY dimension ORDER BY dimension"
|
||||
)).fetchall()
|
||||
no_threshold = conn.execute(text(
|
||||
"SELECT kpi_code, kpi_name FROM kpi_definitions WHERE status='active' AND (threshold_green IS NULL OR threshold_yellow IS NULL OR threshold_red IS NULL)"
|
||||
)).fetchall()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
log(f"✅ 任务1~4完成!KPI字典共 {total} 条")
|
||||
for d, c in by_dim:
|
||||
print(f" {d}: {c} 条")
|
||||
if no_threshold:
|
||||
print(f"\n⚠️ 以下KPI仍缺阈值:")
|
||||
for code, name in no_threshold:
|
||||
print(f" {code}: {name}")
|
||||
else:
|
||||
print("\n✅ 所有KPI均有阈值配置")
|
||||
print("=" * 70)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""迁移脚本:更新现有战略地图KPI codes + 关联所有活跃KPI到地图"""
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
|
||||
def migrate():
|
||||
e = get_engine()
|
||||
with e.begin() as c:
|
||||
# 1. 更新现有地图的dimensions → 使用真实KPI codes
|
||||
maps = c.execute(text('SELECT id, dimensions FROM strategic_maps')).fetchall()
|
||||
for mid, dims_json in maps:
|
||||
if not dims_json:
|
||||
continue
|
||||
dims = json.loads(dims_json) if isinstance(dims_json, str) else dims_json
|
||||
|
||||
# 定义层→节点名→真实KPI code的映射
|
||||
name_to_kpi = {
|
||||
# 财务层
|
||||
"营收目标": ["F_REVENUE"],
|
||||
"净利润率": ["F_NET_PROFIT"],
|
||||
"现金流": ["F_OP_CFLOW"],
|
||||
# 客户层
|
||||
"客户满意度": ["C_SATISFACTION"],
|
||||
"市场份额": ["C_MARKET_SHARE"],
|
||||
"客户保留率": ["C_RETENTION_RATE"],
|
||||
# 流程层
|
||||
"运营效率": ["P_DELIVERY"],
|
||||
"质量合格率": ["P_PASS_RATE"],
|
||||
# 学习层
|
||||
"关键岗位胜任度": ["L_COMPETENCY"],
|
||||
"培训完成率": ["L_TRAINING"],
|
||||
}
|
||||
|
||||
updated = False
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
name = obj.get("name", "")
|
||||
if name in name_to_kpi:
|
||||
real_kpis = name_to_kpi[name]
|
||||
if obj.get("kpis") != real_kpis:
|
||||
obj["kpis"] = real_kpis
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
c.execute(
|
||||
text("UPDATE strategic_maps SET dimensions = :dims WHERE id = :mid"),
|
||||
{"dims": json.dumps(dims, ensure_ascii=False), "mid": mid},
|
||||
)
|
||||
print(f"✅ 地图 {mid}: dimensions KPI codes 已更新")
|
||||
|
||||
# 2. 将所有活跃KPI的map_id更新为1
|
||||
result = c.execute(
|
||||
text("UPDATE kpi_definitions SET map_id = 1 WHERE status = 'active' AND (map_id IS NULL OR map_id != 1)")
|
||||
)
|
||||
affected = result.rowcount
|
||||
print(f"✅ 已更新 {affected} 个活跃KPI的 map_id → 1")
|
||||
|
||||
# 3. 验证
|
||||
total = c.execute(text("SELECT COUNT(*) FROM kpi_definitions WHERE status='active' AND map_id = 1")).scalar()
|
||||
print(f"📊 活跃KPI关联到地图1的数量: {total}")
|
||||
|
||||
kpis_with_data = c.execute(text("""
|
||||
SELECT k.kpi_code, k.kpi_name
|
||||
FROM kpi_definitions k
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM kpi_values v WHERE v.kpi_id = k.id
|
||||
) AND k.map_id = 1
|
||||
ORDER BY k.kpi_code
|
||||
""")).fetchall()
|
||||
print(f"📊 有历史数据的KPI ({len(kpis_with_data)}个):")
|
||||
for r in kpis_with_data:
|
||||
print(f" {r.kpi_code} - {r.kpi_name}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""P2阶段: 预设KPI因果链(基于CMA四层因果链模型)
|
||||
学习成长→内部流程→客户→财务
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.database import get_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
# 基于CMA四层因果链模型的预设因果关系
|
||||
PRESET_CAUSALITIES = [
|
||||
# ===== 学习成长 → 内部流程 =====
|
||||
# 培训完成率 → 过程合格率
|
||||
{"source": "L_TRAINING", "target": "P_PASS_RATE", "strength": 0.6, "lag": 2, "direction": "positive", "formula": "培训提升技能→过程质量提升"},
|
||||
# 人均培训时长 → 缺陷率(负相关)
|
||||
{"source": "L_TRAINING_HOURS", "target": "P_BUG_RATE", "strength": 0.5, "lag": 2, "direction": "negative", "formula": "更多培训→更少缺陷"},
|
||||
# 员工满意度 → 返工率(负相关)
|
||||
{"source": "L_EMPLOYEE_SAT", "target": "P_REWORK_RATE", "strength": 0.4, "lag": 1, "direction": "negative", "formula": "员工满意→更少返工"},
|
||||
# 关键技术掌握率 → 交付及时率
|
||||
{"source": "L_TECH_COVERAGE", "target": "P_DELIVERY", "strength": 0.7, "lag": 1, "direction": "positive", "formula": "技术掌握→交付提升"},
|
||||
# 关键岗位胜任度 → 过程合格率
|
||||
{"source": "L_COMPETENCY", "target": "P_PASS_RATE", "strength": 0.6, "lag": 1, "direction": "positive", "formula": "胜任度→质量提升"},
|
||||
# 创新提案数量 → 新产品收入占比
|
||||
{"source": "L_INNOVATION_COUNT", "target": "P_NEW_PROD_RATIO", "strength": 0.5, "lag": 3, "direction": "positive", "formula": "创新提案→新产品上市"},
|
||||
# 数据自动化率 → 交付及时率
|
||||
{"source": "L_DATA_AUTO_RATE", "target": "P_DELIVERY", "strength": 0.3, "lag": 1, "direction": "positive", "formula": "自动化→效率提升"},
|
||||
# 系统覆盖率 → 供应链响应周期
|
||||
{"source": "L_SYS_COVERAGE", "target": "P_SUPPLY_CYCLE", "strength": 0.4, "lag": 3, "direction": "negative", "formula": "系统覆盖→周期缩短"},
|
||||
# 战略认知度 → 新产品收入占比
|
||||
{"source": "L_STRATEGY_AWARE", "target": "P_NEW_PROD_RATIO", "strength": 0.3, "lag": 2, "direction": "positive", "formula": "战略理解→创新聚焦"},
|
||||
|
||||
# ===== 内部流程 → 客户 =====
|
||||
# 过程合格率 → 客户满意度
|
||||
{"source": "P_PASS_RATE", "target": "C_SATISFACTION", "strength": 0.7, "lag": 1, "direction": "positive", "formula": "质量提升→客户满意"},
|
||||
# 交付及时率 → 客户满意度
|
||||
{"source": "P_DELIVERY", "target": "C_SATISFACTION", "strength": 0.6, "lag": 0, "direction": "positive", "formula": "及时交付→客户满意"},
|
||||
# 缺陷率 → 客户满意度(负相关)
|
||||
{"source": "P_BUG_RATE", "target": "C_SATISFACTION", "strength": 0.5, "lag": 1, "direction": "negative", "formula": "缺陷多→不满意"},
|
||||
# 返工率 → 客户满意度(负相关)
|
||||
{"source": "P_REWORK_RATE", "target": "C_SATISFACTION", "strength": 0.3, "lag": 1, "direction": "negative", "formula": "返工多→不满意"},
|
||||
# 供应链响应周期 → 交付及时率(负相关)
|
||||
{"source": "P_SUPPLY_CYCLE", "target": "P_DELIVERY", "strength": 0.5, "lag": 1, "direction": "negative", "formula": "周期长→交付慢"},
|
||||
# 产能利用率 → 交付及时率
|
||||
{"source": "P_CAPACITY_UTIL", "target": "P_DELIVERY", "strength": 0.4, "lag": 0, "direction": "positive", "formula": "产能足→交付快"},
|
||||
# 新产品收入占比 → 市场份额
|
||||
{"source": "P_NEW_PROD_RATIO", "target": "C_MARKET_SHARE", "strength": 0.5, "lag": 3, "direction": "positive", "formula": "创新产品→市场占有率提升"},
|
||||
|
||||
# ===== 客户 → 财务 =====
|
||||
# 客户满意度 → 客户保留率
|
||||
{"source": "C_SATISFACTION", "target": "C_RETENTION_RATE", "strength": 0.8, "lag": 1, "direction": "positive", "formula": "满意→留存"},
|
||||
# 客户满意度 → 营业收入
|
||||
{"source": "C_SATISFACTION", "target": "F_REVENUE", "strength": 0.5, "lag": 2, "direction": "positive", "formula": "满意客户→复购增加"},
|
||||
# 客户保留率 → 营业收入
|
||||
{"source": "C_RETENTION_RATE", "target": "F_REVENUE", "strength": 0.6, "lag": 1, "direction": "positive", "formula": "老客户留存→稳定收入"},
|
||||
# 客户保留率 → 客户生命周期价值
|
||||
{"source": "C_RETENTION_RATE", "target": "C_LTV", "strength": 0.7, "lag": 2, "direction": "positive", "formula": "高留存→生命周期延长"},
|
||||
# 新客户数 → 营业收入
|
||||
{"source": "C_NEW_CLIENTS", "target": "F_REVENUE", "strength": 0.4, "lag": 1, "direction": "positive", "formula": "新客户→收入增长"},
|
||||
# 市场份额 → 营业收入
|
||||
{"source": "C_MARKET_SHARE", "target": "F_REVENUE", "strength": 0.5, "lag": 1, "direction": "positive", "formula": "市场扩大→收入增加"},
|
||||
# 获客成本 → 净利润(负相关)
|
||||
{"source": "C_ACQUISITION_COST", "target": "F_NET_PROFIT", "strength": 0.3, "lag": 1, "direction": "negative", "formula": "获客成本高→利润减少"},
|
||||
# 客户集中度 → 净利润(负相关)
|
||||
{"source": "C_CUST_CONCENTRATION", "target": "F_NET_PROFIT", "strength": 0.3, "lag": 1, "direction": "negative", "formula": "集中度高→风险增大"},
|
||||
|
||||
# ===== 财务内部因果 =====
|
||||
# 营业收入 → 净利润
|
||||
{"source": "F_REVENUE", "target": "F_NET_PROFIT", "strength": 0.7, "lag": 0, "direction": "positive", "formula": "收入增长→利润增加"},
|
||||
# 毛利率 → 净利润
|
||||
{"source": "F_GROSS_MARGIN", "target": "F_NET_PROFIT", "strength": 0.6, "lag": 0, "direction": "positive", "formula": "毛利提升→利润增加"},
|
||||
# 费用率 → 净利润(负相关)
|
||||
{"source": "F_COST_RATIO", "target": "F_NET_PROFIT", "strength": 0.5, "lag": 0, "direction": "negative", "formula": "费用高→利润减少"},
|
||||
# 收入增长率 → 营业收入
|
||||
{"source": "F_REVENUE_GROWTH", "target": "F_REVENUE", "strength": 0.8, "lag": 1, "direction": "positive", "formula": "增长加速→收入提升"},
|
||||
# 净利润 → 经济增加值
|
||||
{"source": "F_NET_PROFIT", "target": "F_EVA", "strength": 0.9, "lag": 0, "direction": "positive", "formula": "净利润→经济增加值"},
|
||||
# 经营性现金流 → 净利润(滞后反馈)
|
||||
{"source": "F_OP_CFLOW", "target": "F_NET_PROFIT", "strength": 0.4, "lag": 1, "direction": "positive", "formula": "现金充裕→运营改善"},
|
||||
# 产品合格率 → 返工率(负相关)
|
||||
{"source": "F_QUALITY_RATE", "target": "F_REWORK_RATE", "strength": 0.6, "lag": 1, "direction": "negative", "formula": "合格率高→返工少"},
|
||||
# 应收账款周转天数 → 经营性现金流(负相关)
|
||||
{"source": "F_AR_DAYS", "target": "F_OP_CFLOW", "strength": 0.5, "lag": 1, "direction": "negative", "formula": "回款慢→现金流紧张"},
|
||||
# 流动比率 → 资产负债率
|
||||
{"source": "F_CURRENT_RATIO", "target": "F_DEBT_RATIO", "strength": 0.3, "lag": 1, "direction": "negative", "formula": "流动性强→负债率低"},
|
||||
|
||||
# ===== 客户生命周期价值 =====
|
||||
# 净推荐值(NPS) → 客户保留率
|
||||
{"source": "C_NPS", "target": "C_RETENTION_RATE", "strength": 0.6, "lag": 1, "direction": "positive", "formula": "NPS高→留存好"},
|
||||
# 客户生命周期价值 → 净利润
|
||||
{"source": "C_LTV", "target": "F_NET_PROFIT", "strength": 0.5, "lag": 1, "direction": "positive", "formula": "客户终身价值→长期利润"},
|
||||
]
|
||||
|
||||
|
||||
def seed_causalities():
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
# 先构建KPI代码→ID映射
|
||||
kpis = conn.execute(
|
||||
text("SELECT id, kpi_code FROM kpi_definitions WHERE status='active'")
|
||||
).fetchall()
|
||||
code_to_id = {r.kpi_code: r.id for r in kpis}
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for item in PRESET_CAUSALITIES:
|
||||
src_id = code_to_id.get(item["source"])
|
||||
tgt_id = code_to_id.get(item["target"])
|
||||
if not src_id:
|
||||
print(f" ⚠️ 源KPI不存在: {item['source']}")
|
||||
skipped += 1
|
||||
continue
|
||||
if not tgt_id:
|
||||
print(f" ⚠️ 目标KPI不存在: {item['target']}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 检查是否已存在
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM kpi_causality WHERE source_kpi_id=:src AND target_kpi_id=:tgt"),
|
||||
{"src": src_id, "tgt": tgt_id},
|
||||
).fetchone()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
text("""INSERT INTO kpi_causality
|
||||
(source_kpi_id, target_kpi_id, strength, lag_months, formula, direction)
|
||||
VALUES (:src, :tgt, :strength, :lag, :formula, :dir)"""),
|
||||
{
|
||||
"src": src_id, "tgt": tgt_id,
|
||||
"strength": item["strength"],
|
||||
"lag": item["lag"],
|
||||
"formula": item["formula"],
|
||||
"dir": item["direction"],
|
||||
},
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"因果链: 新增{inserted}, 跳过{skipped}")
|
||||
return inserted
|
||||
|
||||
|
||||
def seed_bi_templates():
|
||||
engine = get_engine()
|
||||
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}',
|
||||
},
|
||||
]
|
||||
|
||||
with engine.connect() as conn:
|
||||
inserted = 0
|
||||
for tpl in PRESET_TEMPLATES:
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM bi_report_templates WHERE name=:name AND is_system=1"),
|
||||
{"name": tpl["name"]},
|
||||
).fetchone()
|
||||
if existing:
|
||||
continue
|
||||
conn.execute(
|
||||
text("INSERT INTO bi_report_templates (name, report_type, config, is_system) VALUES (:name, :type, :cfg, 1)"),
|
||||
{"name": tpl["name"], "type": tpl["report_type"], "cfg": tpl["config"]},
|
||||
)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
print(f"报表模板: 新增{inserted}")
|
||||
return inserted
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== 种子数据初始化 ===\n")
|
||||
seed_causalities()
|
||||
seed_bi_templates()
|
||||
print("\n完成!")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user