Files
cma-management/backend/app/api/dashboard.py.bak
T

841 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.cache import get as cache_get, set as cache_set
import json
import logging
logger = logging.getLogger("cma.dashboard")
router = APIRouter(prefix="/api/cma/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
def period_prefix(period_type: str):
"""生成SQL期间前缀匹配"""
if period_type == "month":
return datetime.now().strftime("%Y-%m")
elif period_type == "quarter":
now = datetime.now()
q = (now.month - 1) // 3
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
return months
elif period_type == "year":
return str(datetime.now().year)
return None
@router.get("/summary")
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
cache_key = f"summary:{role}:{period}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
# 读取最近一次同步状态(从日志文件最后一行)
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
try:
with open("/var/log/cma-daily-sync.log", "r") as f:
lines = f.readlines()
# 从最后往前找包含 "完成" 或 "失败" 的行
for line in reversed(lines[-50:]):
if "全部完成" in line:
sync_status["status"] = "success"
sync_status["last_sync"] = line.strip()
break
elif "失败" in line or "ERROR" in line:
sync_status["status"] = "failed"
sync_status["last_sync"] = line.strip()
break
else:
# 没找到完成/失败标记,取最后一行
sync_status["last_sync"] = lines[-1].strip() if lines else None
except Exception as e:
sync_status["detail"] = str(e)
result = {
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
"sync_status": sync_status,
}
cache_set("dashboard", cache_key, result, ttl_seconds=30)
return result
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").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":
months = period_prefix("quarter")
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()
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "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,
})
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
@router.get("/my-kpis")
def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
- 其他角色:看所有有预警的KPI
"""
role = current_user.role
username = current_user.username
name = current_user.name
period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
# business角色筛选
if role == "business":
responsible = (k.responsible_user or "").strip()
if responsible and responsible != username and responsible != name:
continue
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"alert_id": alert.id if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
@router.get("/finance-analysis")
def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""财务工作台分析数据"""
period_str = datetime.now().strftime("%Y-%m")
finance_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "finance",
).all()
kpi_data = []
for k in finance_kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
kpi_data.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"trend": trend,
"alert_level": alert.alert_level if alert else "none",
"frequency": k.frequency,
})
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
return {
"period": period_str,
"kpis": kpi_data,
"summary": {
"total_sales": total_sales["actual_value"] if total_sales else None,
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
"receivable_turnover": receivable["actual_value"] if receivable else None,
}
}
@router.get("/predict")
def predict_kpis(db: Session = Depends(get_db)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
period_str = datetime.now().strftime("%Y-%m")
next_month = int(period_str[5:7]) + 1
next_year = int(period_str[:4])
if next_month > 12:
next_month = 1
next_year += 1
next_period = f"{next_year}-{next_month:02d}"
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
predictions = []
for k in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.asc()).all()
# 需要至少3个数据点才能做预测
if len(values) < 3:
continue
# 简单线性回归: y = a + bx
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
if len(points) < 3:
continue
n = len(points)
sum_x = sum(p[0] for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum(p[0] * p[1] for p in points)
sum_xx = sum(p[0] ** 2 for p in points)
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
denom = n * sum_xx - sum_x * sum_x
if denom == 0:
continue
b = (n * sum_xy - sum_x * sum_y) / denom
a = (sum_y - b * sum_x) / n
# 预测下个月(x = n,因为最后一个索引是 n-1)
predicted_value = a + b * n
# 检查预测值是否触发阈值
alert_level = "none"
if k.threshold_red:
try:
op = k.threshold_red[:2] if len(k.threshold_red) > 1 and k.threshold_red[1] in "=<>" else k.threshold_red[0]
val_str = k.threshold_red.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "red"
except (ValueError, IndexError):
pass
if alert_level == "none" and k.threshold_yellow:
try:
op = k.threshold_yellow[:2] if len(k.threshold_yellow) > 1 and k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
val_str = k.threshold_yellow.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "yellow"
except (ValueError, IndexError):
pass
predictions.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"target_value": k.target_value,
"last_value": points[-1][1] if points else None,
"predicted_value": round(predicted_value, 2),
"predicted_period": next_period,
"alert_level": alert_level,
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
"data_points": len(points),
})
return {
"current_period": period_str,
"next_period": next_period,
"predictions": predictions,
"kpi_count": len(kpis),
"predictable_count": len(predictions),
}
# ── 个人工作台 ──────────────────────────────
@router.get("/my-dashboard")
def my_dashboard(
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
username = current_user.username
name = current_user.name
role = current_user.role
# 角色 → 维度映射(从已发布战略地图中按角色筛选对应维度的KPI)
ROLE_DIMENSIONS = {
"ceo": ["finance", "customer", "process", "learning"], # CEO看全部维度
"finance": ["finance"], # 财务看财务维度
"business": ["customer", "process"], # 业务看客户+流程维度
"it": ["process", "learning"], # IT看流程+学习成长
}
role_dims = ROLE_DIMENSIONS.get(role, ["finance", "customer"])
# 获取所有已发布战略地图的KPI code集合(dimensions中引用的)
from app.models import StrategicMap
published_maps = db.query(StrategicMap).filter(StrategicMap.status == "published").all()
map_kpi_codes = set()
for sm in published_maps:
dims = sm.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
continue
for dim in dims:
for obj in dim.get("objectives", []):
for code in obj.get("kpis", []):
map_kpi_codes.add(code)
# 1. 按角色维度筛选(从已发布地图的KPI中取符合角色维度的)
map_kpis = []
if map_kpi_codes:
map_kpis = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(map_kpi_codes),
KPIDefinition.dimension.in_(role_dims),
KPIDefinition.status == "active",
).all()
# 2. 补充负责的KPIresponsible_user匹配)
assigned_kpis = db.query(KPIDefinition).filter(
or_(
KPIDefinition.responsible_user == username,
KPIDefinition.responsible_user == name,
),
KPIDefinition.status == "active",
).all()
assigned_ids = {k.id for k in assigned_kpis}
# 去重合并
all_kpis = map_kpis + [k for k in assigned_kpis if k.id not in {mk.id for mk in map_kpis}]
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = k.target_value
level = "gray"
if actual is not None and target:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
kpi_list.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"target_value": target,
"actual_value": actual,
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
})
# 2. 我的改善行动(assignee匹配)
from app.models import ActionPlan
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
overdue = False
if p.due_date and p.status not in ("completed", "cancelled"):
overdue = p.due_date < datetime.now()
kpi_name = ""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
if kpi:
kpi_name = kpi.kpi_name
plan_list.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_name": kpi_name,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"status": p.status,
"progress": p.progress or 0,
"due_date": p.due_date.isoformat() if p.due_date else None,
"overdue": overdue,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 3. 待办提醒
reminders = []
# 逾期行动
for p in plan_list:
if p["overdue"]:
reminders.append({
"type": "overdue_plan",
"severity": "danger",
"message": f"你负责的「{p['title']}」已逾期",
"related_id": p["id"],
"related_type": "action_plan",
})
# 红色预警KPI
for k in kpi_list:
if k["level"] == "red":
reminders.append({
"type": "red_kpi",
"severity": "danger",
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
"related_id": k["id"],
"related_type": "kpi",
})
# 黄色预警KPI
for k in kpi_list:
if k["level"] == "yellow":
reminders.append({
"type": "yellow_kpi",
"severity": "warning",
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
"related_id": k["id"],
"related_type": "kpi",
})
return {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
@router.get("/erp-trends")
def get_erp_trends(
current_user: User = Depends(require_auth),
months: int = Query(12, ge=3, le=36),
db: Session = Depends(get_db),
):
"""获取ERP关键指标趋势数据(驾驶舱趋势分析用)"""
codes = [
"F_REVENUE",
"F_PROFIT_RATE",
"F_NET_PROFIT_RATE",
"F_COST_RATIO",
"F_CASH_FLOW",
"F_AR_TURNOVER",
"F_ROE",
"F_ASSET_TURNOVER",
"F_DEBT_RATIO",
"C_CUSTOMER_COUNT",
"C_CUSTOMER_SATISFACTION",
"C_CUSTOMER_CONCENTRATION",
"P_DELIVERY_ON_TIME",
"P_DEFECT_RATE",
"P_SUPPLY_CYCLE",
"L_TRAINING_HOURS",
"L_EMPLOYEE_TURNOVER",
"L_INNOVATION_COUNT",
"L_TECH_COVERAGE",
]
result = {}
for code in codes:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
).order_by(KPIValue.period.desc()).limit(months).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(values)]
if trend:
vals = [v["value"] for v in trend if v["value"] is not None]
latest = vals[-1] if vals else 0
first = vals[0] if vals else 0
if latest > first * 1.05:
trend_dir = "up"
elif latest < first * 0.95:
trend_dir = "down"
else:
trend_dir = "stable"
mom_val = vals[-2] if len(vals) >= 2 else None
yoy_val = vals[-12] if len(vals) >= 12 else (vals[0] if len(vals) >= 1 else None)
result[code] = {
"name": kpi.kpi_name,
"unit": kpi.unit or "",
"target": kpi.target_value,
"trend": trend,
"trend_dir": trend_dir,
"latest": latest,
"mom": mom_val,
"mom_rate": round((latest - mom_val) / abs(mom_val) * 100, 1) if mom_val and mom_val != 0 else None,
"yoy": yoy_val,
"yoy_rate": round((latest - yoy_val) / abs(yoy_val) * 100, 1) if yoy_val and yoy_val != 0 else None,
}
return {"data": result}
@router.get("/dupont")
async def dupont_analysis(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""杜邦分析 — ROE分解
ROE = 净利率 × 资产周转率 × 权益乘数
"""
cache_key = f"dupont:{current_user.role}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
# 获取底层数据KPI
def get_kpi_value(code: str) -> tuple:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
return None, None, None
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).first()
prev = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).offset(1).first()
val = latest.actual_value if latest else None
pval = prev.actual_value if prev else None
return val, pval, kpi.unit
# 营收、利润、总资产、净资产
revenue, prev_revenue, _ = get_kpi_value("F_REVENUE")
# 用营收×净利润率估算净利润(数据库没有净利润绝对值)
profit_net = None
prev_profit_net = None
if revenue:
net_profit_rate, prev_npr, _ = get_kpi_value("F_NET_PROFIT_RATE")
if net_profit_rate:
profit_net = revenue * (net_profit_rate / 100)
if prev_revenue and prev_npr:
prev_profit_net = prev_revenue * (prev_npr / 100)
# 如果还是算不出来,用毛利率做替代估算
if profit_net is None and revenue:
gross_profit, _, _ = get_kpi_value("F_PROFIT_RATE")
profit_net = revenue * (gross_profit / 100) * 0.7 if gross_profit else None # 粗略估算净利润=毛利*0.7
asset_total, prev_asset, _ = get_kpi_value("F_ASSET_TOTAL")
equity_total, prev_equity, _ = get_kpi_value("F_EQUITY_TOTAL")
# 计算杜邦因子
result = {"roe": None, "factors": {}, "raw_data": {}, "history": {}}
if revenue and profit_net and asset_total and equity_total and all(v > 0 for v in [revenue, asset_total, equity_total]):
net_profit_margin = round(profit_net / revenue, 4) # 净利率
asset_turnover = round(revenue / asset_total, 4) # 资产周转率
equity_multiplier = round(asset_total / equity_total, 4) # 权益乘数
roe = round(net_profit_margin * asset_turnover * equity_multiplier * 100, 2)
result["roe"] = roe
result["factors"] = {
"net_profit_margin": {"value": net_profit_margin, "label": "净利率", "desc": f"净利润/{'营收' if revenue else '-'} = {net_profit_margin*100:.2f}%"},
"asset_turnover": {"value": asset_turnover, "label": "资产周转率", "desc": f"营收/总资产 = {asset_turnover:.4f}次"},
"equity_multiplier": {"value": equity_multiplier, "label": "权益乘数", "desc": f"总资产/净资产 = {equity_multiplier:.4f}"},
}
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
# 环比计算
if prev_revenue and prev_profit_net and prev_asset and prev_equity and all(v > 0 for v in [prev_revenue, prev_asset, prev_equity]):
prev_npm = round(prev_profit_net / prev_revenue, 4)
prev_at = round(prev_revenue / prev_asset, 4)
prev_em = round(prev_asset / prev_equity, 4)
prev_roe = round(prev_npm * prev_at * prev_em * 100, 2)
result["history"]["prev"] = {
"roe": prev_roe,
"net_profit_margin": prev_npm,
"asset_turnover": prev_at,
"equity_multiplier": prev_em,
}
# 同比变化
change = round(roe - prev_roe, 2)
npm_change = round((net_profit_margin - prev_npm) * 10000, 2) # 转成BP
at_change = round(asset_turnover - prev_at, 4)
em_change = round(equity_multiplier - prev_em, 4)
result["history"]["change"] = {
"roe": change,
"roe_label": f"{'+' if change > 0 else ''}{change}%",
"net_profit_margin_bp": npm_change,
"asset_turnover": at_change,
"equity_multiplier": em_change,
}
result["history"]["trend"] = "up" if change > 0 else ("down" if change < 0 else "stable")
# 补上原始数据(即使计算不全也返回给前端展示)
if not result.get("raw_data"):
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
cache_set("dashboard", cache_key, result, ttl_seconds=300)
return result
def _get_kpi_trend(kpi_id: int, db: Session) -> dict:
"""计算KPI的环比和同比趋势"""
from datetime import datetime
now = datetime.now()
cur_period = now.strftime("%Y-%m")
# 上月
if now.month == 1:
prev_month = f"{now.year-1}-12"
else:
prev_month = f"{now.year}-{now.month-1:02d}"
# 去年同期
last_year = f"{now.year-1}-{now.month:02d}"
cur_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == cur_period
).order_by(KPIValue.id.desc()).first()
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == prev_month
).order_by(KPIValue.id.desc()).first()
yoy_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == last_year
).order_by(KPIValue.id.desc()).first()
def calc_rate(curr, prev):
if curr and prev and prev.actual_value and prev.actual_value != 0:
return round((curr.actual_value - prev.actual_value) / prev.actual_value * 100, 2)
return None
return {
"current_value": cur_val.actual_value if cur_val else None,
"current_period": cur_period,
"mom_value": prev_val.actual_value if prev_val else None,
"mom_rate": calc_rate(cur_val, prev_val),
"yoy_value": yoy_val.actual_value if yoy_val else None,
"yoy_rate": None if not yoy_val else calc_rate(cur_val, yoy_val),
}
@router.get("/kpis/enhanced")
def get_kpis_enhanced(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = None, end_date: str = None,
db: Session = Depends(get_db)):
"""增强版KPI列表(带趋势)"""
result = get_dashboard_kpis(role=role, period=period, start_date=start_date, end_date=end_date, db=db)
if "data" in result and result["data"]:
for kpi in result["data"]:
if kpi.get("id"):
trend = _get_kpi_trend(kpi["id"], db)
kpi["trend"] = trend
return result
@router.get("/trend-analysis")
def get_trend_analysis(kpi_ids: str = Query(""), period: str = Query("month"),
db: Session = Depends(get_db)):
"""多KPI趋势对比(折线图数据)"""
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
if not ids:
return {"data": []}
result = []
for kpi_id in ids:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id
).order_by(KPIValue.period).all()
series = []
for v in values:
if v.actual_value is not None:
series.append({
"period": v.period,
"value": v.actual_value,
})
result.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"target": kpi.target_value,
"data": series,
})
return {"data": result}
@router.get("/alert-stats")
def get_alert_stats(period: str = Query("month"), db: Session = Depends(get_db)):
"""预警统计(按等级和维度)"""
from sqlalchemy import func
# 按等级统计
by_level = db.query(
KPIAlert.alert_level,
func.count(KPIAlert.id)
).group_by(KPIAlert.alert_level).all()
level_stats = {row[0]: row[1] for row in by_level}
# 按维度统计
by_dim = db.query(
KPIDefinition.dimension,
func.count(KPIAlert.id)
).join(KPIAlert, KPIDefinition.id == KPIAlert.kpi_id
).group_by(KPIDefinition.dimension).all()
dim_stats = {row[0]: row[1] for row in by_dim}
return {
"by_level": level_stats,
"by_dimension": dim_stats,
"total": sum(level_stats.values()) if level_stats else 0,
}
@router.get("/export")
def export_kpi_data(kpi_ids: str = "", db: Session = Depends(get_db)):
"""导出KPI数据为CSV格式"""
from fastapi.responses import PlainTextResponse
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
query = db.query(KPIValue).join(KPIDefinition, KPIValue.kpi_id == KPIDefinition.id)
if ids:
query = query.filter(KPIValue.kpi_id.in_(ids))
rows = query.order_by(KPIDefinition.kpi_code, KPIValue.period).all()
csv_lines = ["KPI编码,KPI名称,期间,实际值,目标值,来源,状态"]
for r in rows:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
csv_lines.append(f"{kpi.kpi_code},{kpi.kpi_name},{r.period},{r.actual_value},{kpi.target_value},{r.source_type},{r.data_status}")
return PlainTextResponse("\n".join(csv_lines), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=kpi_export.csv"})