feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""客户维度KPI看板 API — P0-2"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.customer")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_customer_kpis(
|
||||
period: str = Query("month"),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
# 只查 customer 维度的 KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
q_month = (datetime.now().month - 1) // 3
|
||||
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 最新预警
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
# 趋势(环比变化率)
|
||||
trend = None
|
||||
achievement_rate = None
|
||||
period_values = []
|
||||
|
||||
if latest and latest.actual_value:
|
||||
prev_period_str = None
|
||||
if period == "month":
|
||||
year_s, month_s = period_str.split("-")
|
||||
y_s, m_s = int(year_s), int(month_s)
|
||||
m_s -= 1
|
||||
if m_s <= 0:
|
||||
m_s += 12
|
||||
y_s -= 1
|
||||
prev_period_str = f"{y_s}-{m_s:02d}"
|
||||
|
||||
if prev_period_str:
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == prev_period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
|
||||
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
|
||||
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
|
||||
trend = 100.0 if latest.actual_value > 0 else 0
|
||||
|
||||
# 达成率
|
||||
if latest and latest.actual_value and k.target_value and k.target_value > 0:
|
||||
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
|
||||
|
||||
# 最近6期趋势数据
|
||||
period_q = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
period_values = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(period_q) if v.actual_value is not None
|
||||
]
|
||||
|
||||
result.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"achievement_rate": achievement_rate,
|
||||
"period_values": period_values,
|
||||
"kpi_name": k.kpi_name,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/trend/{kpi_id}")
|
||||
def get_kpi_trend(
|
||||
kpi_id: int,
|
||||
months: int = Query(12, ge=3, le=24),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个KPI的历史趋势数据"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取最近N期数据
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend_data = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(values) if v.actual_value is not None
|
||||
]
|
||||
|
||||
# 计算预警水平和触发时间
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
).order_by(KPIAlert.created_at.desc()).limit(10).all()
|
||||
|
||||
alert_logs = [
|
||||
{
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"time": a.created_at.isoformat() if a.created_at else None,
|
||||
"status": a.status,
|
||||
}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"target_value": kpi.target_value,
|
||||
"unit": kpi.unit,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
},
|
||||
"trend_data": trend_data,
|
||||
"alerts": alert_logs,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_customer_summary(
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""客户维度概要统计"""
|
||||
total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).scalar() or 0
|
||||
|
||||
# 预警统计
|
||||
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.kpi_id.in_(
|
||||
db.query(KPIDefinition.id).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
)
|
||||
),
|
||||
).scalar() or 0
|
||||
|
||||
# 二级类别分布
|
||||
cat_stats = db.query(
|
||||
KPIDefinition.category,
|
||||
func.count(KPIDefinition.id),
|
||||
).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).group_by(KPIDefinition.category).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending_alerts": pending_alerts,
|
||||
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
|
||||
}
|
||||
Reference in New Issue
Block a user