feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+840
View File
@@ -0,0 +1,840 @@
"""驾驶舱 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"})