seaislee1209 becfd74efd
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m44s
Build and Deploy Web / build-and-deploy (push) Successful in 57s
feat: 内部事务提交功能(培训/招聘面试/内部其他)
- 新增 ContentType: TRAINING, RECRUITMENT, INTERNAL_OTHER
- 新增 PhaseGroup: INTERNAL (内部事务)
- 前端选"内部事务"项目时隐藏项目阶段/工作类型/产出时长
- 内容类型改显示为"事务类型"(培训/招聘面试/内部其他)
- 报告和仪表盘中排除内部事务项目
- 内部事务成本后续按管理成本逻辑分摊到所有项目

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:18:48 +08:00

337 lines
14 KiB
Python
Raw Permalink 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.

"""仪表盘 + 结算路由"""
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, PhaseGroup
)
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_management_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,
Project.name != "内部事务",
).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)
# 本月人力成本 = 提交人成本 + 管理层成本
# 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
# 2. 管理层成本(豁免提交角色的人)
from models import Role
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
if exempt_role_ids:
exempt_users = db.query(User).filter(
User.is_active == 1,
User.monthly_salary > 0,
User.role_id.in_(exempt_role_ids),
).all()
# 本月有提交记录的工作日数
monthly_working_days = db.query(Submission.submit_date).filter(
Submission.submit_date >= month_start,
Submission.submit_date <= today,
).distinct().count()
# 管理层成本 = 每人日薪 × 本月工作日数
monthly_management = sum(u.daily_cost * monthly_working_days for u in exempt_users)
monthly_labor += monthly_management
# 本月 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,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
# 活跃人数(只算有中期提交的人,与产出口径一致)
active_users = db.query(Submission.user_id).filter(
Submission.submit_date >= month_start,
Submission.submit_date <= today,
Submission.project_phase == PhaseGroup.PRODUCTION,
).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)
# 只用中期制作产出算进度,不把后期算进去
production_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0,
Submission.project_phase == PhaseGroup.PRODUCTION,
).scalar() or 0
target = p.target_total_seconds
progress = round(production_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": round(production_secs, 1),
"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,
Submission.project_phase == PhaseGroup.PRODUCTION,
).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
total_management_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)
total_management_all += calc_management_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)},
{"name": "管理成本", "value": round(total_management_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,
Submission.project_phase == PhaseGroup.PRODUCTION,
).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) + calc_management_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)
# ── 今日提交情况(排除豁免角色) ──
from models import Role
exempt_role_ids = set(
r.id for r in db.query(Role).filter(Role.exempt_submission == 1).all()
)
all_active_users = [
u for u in db.query(User).filter(User.is_active == 1).all()
if u.role_id not in exempt_role_ids
]
submitted_user_ids = set(
uid for (uid,) in db.query(Submission.user_id).filter(
Submission.submit_date == today,
).distinct().all()
)
submitted_sufficient = [] # 提交且满8小时
submitted_insufficient = [] # 提交但不足8小时
not_submitted_users = []
for u in all_active_users:
info = {"id": u.id, "name": u.name}
if u.id in submitted_user_ids:
hours = db.query(sa_func.sum(Submission.hours_spent)).filter(
Submission.user_id == u.id,
Submission.submit_date == today,
).scalar() or 0
info["hours"] = round(hours, 1)
if hours >= 8:
submitted_sufficient.append(info)
else:
submitted_insufficient.append(info)
else:
not_submitted_users.append(info)
daily_attendance = {
"total": len(all_active_users),
"submitted_sufficient_count": len(submitted_sufficient),
"submitted_insufficient_count": len(submitted_insufficient),
"not_submitted_count": len(not_submitted_users),
"submitted_sufficient": submitted_sufficient,
"submitted_insufficient": submitted_insufficient,
"not_submitted": not_submitted_users,
}
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_attendance": daily_attendance,
# 图表数据
"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)