- 仪表盘双色进度条(超100%蓝红分段)、工时损耗展示、chart tooltip增强 - 修复 Submissions.vue 延期原因字段始终显示的Bug - 修复 SPA catch-all 路由拦截 API 请求(去尾部斜杠) - seed_demo.py 重写:5项目/4类型/32里程碑/124提交,真实时间线 - 三阶段损耗计算(前期工时/制作秒数/后期工时) - ContentType 扩展为11种,里程碑增强(预估天数/开始日期/超期检测) - 更新 PRD 和项目总结文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
9.9 KiB
Python
258 lines
9.9 KiB
Python
"""仪表盘 + 结算路由"""
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import func as sa_func
|
||
from datetime import date, timedelta
|
||
from database import get_db
|
||
from models import (
|
||
User, Project, Submission, AIToolCost,
|
||
ProjectStatus, ProjectType, WorkType
|
||
)
|
||
from auth import get_current_user, require_permission
|
||
from calculations import (
|
||
calc_project_settlement, calc_waste_for_project,
|
||
calc_labor_cost_for_project, calc_ai_tool_cost_for_project,
|
||
calc_outsource_cost_for_project, calc_overhead_cost_for_project,
|
||
calc_team_efficiency
|
||
)
|
||
|
||
router = APIRouter(prefix="/api", tags=["仪表盘与结算"])
|
||
|
||
|
||
@router.get("/dashboard")
|
||
def get_dashboard(
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_permission("dashboard:view"))
|
||
):
|
||
"""全局仪表盘数据"""
|
||
# 项目概览
|
||
active = db.query(Project).filter(Project.status == ProjectStatus.IN_PROGRESS).all()
|
||
completed = db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all()
|
||
abandoned = db.query(Project).filter(Project.status == ProjectStatus.ABANDONED).all()
|
||
|
||
# 当月日期范围
|
||
today = date.today()
|
||
month_start = today.replace(day=1)
|
||
|
||
# 本月人力成本(简化:统计本月所有有提交的人的日成本)
|
||
monthly_submitters = db.query(Submission.user_id, Submission.submit_date).filter(
|
||
Submission.submit_date >= month_start,
|
||
Submission.submit_date <= today,
|
||
).distinct().all()
|
||
|
||
monthly_labor = 0.0
|
||
processed_user_dates = set()
|
||
for uid, d in monthly_submitters:
|
||
key = (uid, d)
|
||
if key not in processed_user_dates:
|
||
processed_user_dates.add(key)
|
||
user = db.query(User).filter(User.id == uid).first()
|
||
if user:
|
||
monthly_labor += user.daily_cost
|
||
|
||
# 本月 AI 工具成本
|
||
monthly_ai = db.query(sa_func.sum(AIToolCost.amount)).filter(
|
||
AIToolCost.record_date >= month_start,
|
||
AIToolCost.record_date <= today,
|
||
).scalar() or 0
|
||
|
||
# 本月总产出秒数
|
||
monthly_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||
Submission.submit_date >= month_start,
|
||
Submission.submit_date <= today,
|
||
Submission.total_seconds > 0,
|
||
).scalar() or 0
|
||
|
||
# 活跃人数
|
||
active_users = db.query(Submission.user_id).filter(
|
||
Submission.submit_date >= month_start,
|
||
).distinct().count()
|
||
working_days = max(1, (today - month_start).days + 1)
|
||
avg_daily = round(monthly_secs / max(1, active_users) / working_days, 1)
|
||
|
||
# 各项目摘要
|
||
project_summaries = []
|
||
for p in active:
|
||
waste = calc_waste_for_project(p.id, db)
|
||
total_secs = waste.get("total_submitted_seconds", 0)
|
||
target = p.target_total_seconds
|
||
progress = round(total_secs / target * 100, 1) if target > 0 else 0
|
||
is_overdue = (
|
||
p.estimated_completion_date and today > p.estimated_completion_date
|
||
)
|
||
project_summaries.append({
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"project_type": p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
|
||
"progress_percent": progress,
|
||
"target_seconds": target,
|
||
"submitted_seconds": total_secs,
|
||
"waste_rate": waste.get("waste_rate", 0),
|
||
"waste_hours": waste.get("total_waste_hours", 0),
|
||
"is_overdue": bool(is_overdue),
|
||
"estimated_completion_date": str(p.estimated_completion_date) if p.estimated_completion_date else None,
|
||
})
|
||
|
||
# 损耗排行(含废弃项目,废弃项目全部产出记为损耗)
|
||
waste_ranking = []
|
||
total_waste_seconds_all = 0.0
|
||
total_waste_hours_all = 0.0
|
||
total_target_seconds_all = 0.0
|
||
for p in active + completed + abandoned:
|
||
w = calc_waste_for_project(p.id, db)
|
||
total_waste_seconds_all += w.get("total_waste_seconds", 0)
|
||
total_waste_hours_all += w.get("total_waste_hours", 0)
|
||
total_target_seconds_all += p.target_total_seconds or 0
|
||
if w.get("total_waste_seconds", 0) > 0 or w.get("total_waste_hours", 0) > 0:
|
||
waste_ranking.append({
|
||
"project_id": p.id,
|
||
"project_name": p.name,
|
||
"waste_seconds": w["total_waste_seconds"],
|
||
"waste_hours": w.get("total_waste_hours", 0),
|
||
"waste_rate": w["waste_rate"],
|
||
})
|
||
waste_ranking.sort(key=lambda x: x["waste_rate"], reverse=True)
|
||
total_waste_rate = round(total_waste_seconds_all / total_target_seconds_all * 100, 1) if total_target_seconds_all > 0 else 0
|
||
|
||
# 已结算项目
|
||
settled = []
|
||
for p in completed:
|
||
settlement = calc_project_settlement(p.id, db)
|
||
settled.append({
|
||
"project_id": p.id,
|
||
"project_name": p.name,
|
||
"project_type": settlement.get("project_type", ""),
|
||
"total_cost": settlement.get("total_cost", 0),
|
||
"contract_amount": settlement.get("contract_amount"),
|
||
"profit_loss": settlement.get("profit_loss"),
|
||
})
|
||
|
||
# ── 图表数据:近30天每日产出趋势 ──
|
||
daily_trend = []
|
||
for i in range(29, -1, -1):
|
||
d = today - timedelta(days=i)
|
||
day_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||
Submission.submit_date == d,
|
||
Submission.total_seconds > 0,
|
||
).scalar() or 0
|
||
daily_trend.append({
|
||
"date": str(d),
|
||
"seconds": round(day_secs, 1),
|
||
})
|
||
|
||
# ── 图表数据:成本构成 ──
|
||
total_labor_all = 0.0
|
||
total_ai_all = 0.0
|
||
total_outsource_all = 0.0
|
||
total_overhead_all = 0.0
|
||
for p in active + completed + abandoned:
|
||
total_labor_all += calc_labor_cost_for_project(p.id, db)
|
||
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
|
||
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
|
||
total_overhead_all += calc_overhead_cost_for_project(p.id, db)
|
||
|
||
cost_breakdown = [
|
||
{"name": "人力成本", "value": round(total_labor_all, 0)},
|
||
{"name": "AI工具", "value": round(total_ai_all, 0)},
|
||
{"name": "外包", "value": round(total_outsource_all, 0)},
|
||
{"name": "固定开支", "value": round(total_overhead_all, 0)},
|
||
]
|
||
|
||
# ── 图表数据:各项目产出对比(进行中项目) ──
|
||
project_comparison = []
|
||
for p in active:
|
||
total_secs_p = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||
Submission.project_id == p.id,
|
||
Submission.total_seconds > 0,
|
||
).scalar() or 0
|
||
project_comparison.append({
|
||
"name": p.name,
|
||
"submitted": round(total_secs_p, 0),
|
||
"target": p.target_total_seconds,
|
||
})
|
||
|
||
# ── 盈利概览 ──
|
||
total_contract = 0.0
|
||
total_cost_completed = 0.0
|
||
for s in settled:
|
||
if s.get("contract_amount"):
|
||
total_contract += s["contract_amount"]
|
||
total_cost_completed += s.get("total_cost", 0)
|
||
total_profit = total_contract - total_cost_completed
|
||
profit_rate = round(total_profit / total_contract * 100, 1) if total_contract > 0 else 0
|
||
|
||
# 进行中项目的合同额和当前成本
|
||
in_progress_contract = 0.0
|
||
in_progress_cost = 0.0
|
||
for p in active:
|
||
if p.contract_amount:
|
||
in_progress_contract += p.contract_amount
|
||
in_progress_cost += calc_labor_cost_for_project(p.id, db) + calc_ai_tool_cost_for_project(p.id, db) + calc_outsource_cost_for_project(p.id, db) + calc_overhead_cost_for_project(p.id, db)
|
||
|
||
# 每个项目的盈亏(用于柱状图)
|
||
profit_by_project = []
|
||
for s in settled:
|
||
if s.get("contract_amount") and s["contract_amount"] > 0:
|
||
profit_by_project.append({
|
||
"name": s["project_name"],
|
||
"profit": round((s.get("contract_amount", 0) or 0) - s.get("total_cost", 0), 0),
|
||
})
|
||
|
||
profitability = {
|
||
"total_contract": round(total_contract, 0),
|
||
"total_cost": round(total_cost_completed, 0),
|
||
"total_profit": round(total_profit, 0),
|
||
"profit_rate": profit_rate,
|
||
"in_progress_contract": round(in_progress_contract, 0),
|
||
"in_progress_cost": round(in_progress_cost, 0),
|
||
"profit_by_project": profit_by_project,
|
||
}
|
||
|
||
# ── 风险预警 ──
|
||
from services.report_service import analyze_project_risks
|
||
risk_alerts = analyze_project_risks(db)
|
||
|
||
return {
|
||
"active_projects": len(active),
|
||
"completed_projects": len(completed),
|
||
"monthly_labor_cost": round(monthly_labor, 2),
|
||
"monthly_ai_tool_cost": round(monthly_ai, 2),
|
||
"monthly_total_seconds": round(monthly_secs, 1),
|
||
"avg_daily_seconds_per_person": avg_daily,
|
||
"projects": project_summaries,
|
||
"total_waste_seconds": round(total_waste_seconds_all, 0),
|
||
"total_waste_hours": round(total_waste_hours_all, 0),
|
||
"total_waste_rate": total_waste_rate,
|
||
"waste_ranking": waste_ranking,
|
||
"settled_projects": settled,
|
||
"profitability": profitability,
|
||
"risk_alerts": risk_alerts,
|
||
# 图表数据
|
||
"daily_trend": daily_trend,
|
||
"cost_breakdown": cost_breakdown,
|
||
"project_comparison": project_comparison,
|
||
}
|
||
|
||
|
||
@router.get("/projects/{project_id}/settlement")
|
||
def get_settlement(
|
||
project_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_permission("settlement:view"))
|
||
):
|
||
"""项目结算报告"""
|
||
project = db.query(Project).filter(Project.id == project_id).first()
|
||
if not project:
|
||
raise HTTPException(status_code=404, detail="项目不存在")
|
||
return calc_project_settlement(project_id, db)
|
||
|
||
|
||
@router.get("/projects/{project_id}/efficiency")
|
||
def get_efficiency(
|
||
project_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_permission("efficiency:view"))
|
||
):
|
||
"""项目团队效率数据"""
|
||
return calc_team_efficiency(project_id, db)
|