seaislee1209 f07126e0ca
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 2m4s
Build and Deploy Web / build-and-deploy (push) Successful in 1m13s
feat: 内容提交表单全面优化 — 阶段重命名+内容类型扩展+集数条件必填
- 阶段重命名: "制作" → "中期",全前后端同步
- 新增前期内容类型: 大纲/梗概、概念设计图、测试片
- 配音改名为AI配音(零迁移,DB存英文NAME)
- 集数按内容类型条件显示: 项目级隐藏,集数级必填
- 工作类型全阶段统一: 制作/修改/测试/QC
- 前期内容隐藏产出时长字段
- 必填校验: 描述+投入时长+集数(条件)
- 今日工时进度条(8h目标)+提交后提醒
- 效率计算只统计中期阶段
- EP集数进度查询优化(N+1→批量GROUP BY)
- 提交列表增加按提交人筛选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:11 +08:00

398 lines
14 KiB
Python
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.

"""项目管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from typing import List, Optional
from datetime import datetime
from database import get_db
from models import (
User, Project, Submission, ProjectType,
ProjectStatus, PhaseGroup, WorkType,
ProjectMilestone, DEFAULT_MILESTONES
)
from schemas import (
ProjectCreate, ProjectUpdate, ProjectOut,
MilestoneOut, MilestoneCreate, MilestoneUpdate
)
from calculations import calc_waste_for_project, _working_days_between
from auth import get_current_user, require_permission
from datetime import date as date_type
router = APIRouter(prefix="/api/projects", tags=["项目管理"])
def _build_milestone_out(m) -> MilestoneOut:
"""构造里程碑输出(含计算字段)"""
today = date_type.today()
actual_days = None
is_overdue = False
if m.start_date:
end = m.completed_at if m.is_completed and m.completed_at else today
actual_days = _working_days_between(m.start_date, end)
if m.estimated_days and actual_days > m.estimated_days:
is_overdue = True
return MilestoneOut(
id=m.id, name=m.name,
phase=m.phase.value if hasattr(m.phase, 'value') else m.phase,
is_completed=bool(m.is_completed),
completed_at=m.completed_at,
sort_order=m.sort_order,
estimated_days=m.estimated_days,
start_date=m.start_date,
actual_days=actual_days,
is_overdue=is_overdue,
)
def enrich_project(p: Project, db: Session) -> ProjectOut:
"""将项目对象转为带计算字段的输出"""
# 累计提交秒数
total_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
Submission.project_id == p.id,
Submission.total_seconds > 0
).scalar() or 0
target = p.target_total_seconds
progress = round(total_secs / target * 100, 1) if target > 0 else 0
# 集中损耗计算
waste_data = calc_waste_for_project(p.id, db)
waste_seconds = waste_data.get("total_waste_seconds", 0)
waste_hours = waste_data.get("total_waste_hours", 0)
waste_rate = waste_data.get("waste_rate", 0)
leader_name = p.leader.name if p.leader else None
# 里程碑数据
ms_rows = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == p.id
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
milestones_out = [_build_milestone_out(m) for m in ms_rows]
# 阶段摘要
pre_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "前期"]
post_ms = [m for m in ms_rows if (m.phase.value if hasattr(m.phase, 'value') else m.phase) == "后期"]
pre_completed = sum(1 for m in pre_ms if m.is_completed)
post_completed = sum(1 for m in post_ms if m.is_completed)
phase_summary = {
"pre": {
"total": len(pre_ms), "completed": pre_completed,
"waste_hours": waste_data.get("pre_waste", {}).get("waste_hours", 0),
},
"production": {
"progress_percent": progress,
"submitted_seconds": round(total_secs, 1),
"target_seconds": target,
"waste": waste_data.get("production_waste", {}),
},
"post": {
"total": len(post_ms), "completed": post_completed,
"waste": waste_data.get("post_waste", {}),
},
}
# 自动推断当前阶段
if len(pre_ms) > 0 and pre_completed < len(pre_ms):
current_stage = "前期"
elif progress < 100:
current_stage = "中期"
elif len(post_ms) > 0 and post_completed < len(post_ms):
current_stage = "后期"
else:
current_stage = "已完成"
# EP 集数进度(批量查询,避免 N+1
episode_progress = []
ep_target = p.episode_duration_minutes * 60 # 每集目标秒数
# 一次查出所有有提交的集数数据
ep_rows = db.query(
Submission.episode_number,
User.name,
sa_func.sum(Submission.total_seconds).label("secs"),
).join(User, User.id == Submission.user_id, isouter=True).filter(
Submission.project_id == p.id,
Submission.episode_number.isnot(None),
Submission.total_seconds > 0,
).group_by(Submission.episode_number, User.name).all()
# 按集数聚合
ep_data = {} # {ep: {total, contributors: {name: secs}}}
for ep_num, user_name, secs in ep_rows:
if ep_num not in ep_data:
ep_data[ep_num] = {"total": 0, "contributors": {}}
ep_data[ep_num]["total"] += secs
name = user_name or "未知"
ep_data[ep_num]["contributors"][name] = ep_data[ep_num]["contributors"].get(name, 0) + secs
for ep in range(1, p.episode_count + 1):
info = ep_data.get(ep, {"total": 0, "contributors": {}})
ep_total = info["total"]
episode_progress.append({
"episode": ep,
"total_seconds": round(ep_total, 1),
"target_seconds": ep_target,
"progress_percent": round(ep_total / ep_target * 100, 1) if ep_target > 0 else 0,
"contributors": [
{"name": k, "seconds": round(v, 1)}
for k, v in sorted(info["contributors"].items(), key=lambda x: -x[1])
],
})
return ProjectOut(
id=p.id, name=p.name,
project_type=p.project_type.value if hasattr(p.project_type, 'value') else p.project_type,
status=p.status.value if hasattr(p.status, 'value') else p.status,
leader_id=p.leader_id, leader_name=leader_name,
current_phase=p.current_phase.value if hasattr(p.current_phase, 'value') else p.current_phase,
episode_duration_minutes=p.episode_duration_minutes,
episode_count=p.episode_count,
target_total_seconds=target,
estimated_completion_date=p.estimated_completion_date,
actual_completion_date=p.actual_completion_date,
contract_amount=p.contract_amount,
created_at=p.created_at,
total_submitted_seconds=round(total_secs, 1),
progress_percent=progress,
waste_seconds=round(waste_seconds, 1),
waste_hours=waste_hours,
waste_rate=waste_rate,
milestones=milestones_out,
phase_summary=phase_summary,
current_stage=current_stage,
episode_progress=episode_progress,
)
@router.get("", response_model=List[ProjectOut])
def list_projects(
status: Optional[str] = Query(None),
project_type: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
q = db.query(Project)
if status:
q = q.filter(Project.status == ProjectStatus(status))
if project_type:
q = q.filter(Project.project_type == ProjectType(project_type))
projects = q.order_by(Project.created_at.desc()).all()
return [enrich_project(p, db) for p in projects]
@router.post("", response_model=ProjectOut)
def create_project(
req: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:create"))
):
project = Project(
name=req.name,
project_type=ProjectType(req.project_type),
leader_id=req.leader_id,
current_phase=PhaseGroup(req.current_phase),
episode_duration_minutes=req.episode_duration_minutes,
episode_count=req.episode_count,
estimated_completion_date=req.estimated_completion_date,
contract_amount=req.contract_amount,
)
db.add(project)
db.flush()
# 创建里程碑
ms_list = req.milestones if req.milestones else DEFAULT_MILESTONES
for ms in ms_list:
db.add(ProjectMilestone(
project_id=project.id,
name=ms["name"],
phase=PhaseGroup(ms["phase"]),
sort_order=ms.get("sort_order", 0),
))
db.commit()
db.refresh(project)
return enrich_project(project, db)
@router.get("/{project_id}", response_model=ProjectOut)
def get_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
return enrich_project(p, db)
@router.put("/{project_id}", response_model=ProjectOut)
def update_project(
project_id: int,
req: ProjectUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
if req.name is not None:
p.name = req.name
if req.project_type is not None:
p.project_type = ProjectType(req.project_type)
if req.status is not None:
new_status = ProjectStatus(req.status)
p.status = new_status
if new_status in (ProjectStatus.IN_PROGRESS, ProjectStatus.ABANDONED):
p.actual_completion_date = None
if req.leader_id is not None:
p.leader_id = req.leader_id
if req.current_phase is not None:
p.current_phase = PhaseGroup(req.current_phase)
if req.episode_duration_minutes is not None:
p.episode_duration_minutes = req.episode_duration_minutes
if req.episode_count is not None:
p.episode_count = req.episode_count
if req.estimated_completion_date is not None:
p.estimated_completion_date = req.estimated_completion_date
if req.actual_completion_date is not None:
p.actual_completion_date = req.actual_completion_date
if req.contract_amount is not None:
p.contract_amount = req.contract_amount
db.commit()
db.refresh(p)
return enrich_project(p, db)
@router.delete("/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:delete"))
):
"""删除项目及其关联数据"""
from models import OutsourceCost, AIToolCostAllocation, CostOverride, SubmissionHistory
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
# 删除关联数据
subs = db.query(Submission).filter(Submission.project_id == project_id).all()
for s in subs:
db.query(SubmissionHistory).filter(SubmissionHistory.submission_id == s.id).delete()
db.query(Submission).filter(Submission.project_id == project_id).delete()
db.query(OutsourceCost).filter(OutsourceCost.project_id == project_id).delete()
db.query(AIToolCostAllocation).filter(AIToolCostAllocation.project_id == project_id).delete()
db.query(CostOverride).filter(CostOverride.project_id == project_id).delete()
db.query(ProjectMilestone).filter(ProjectMilestone.project_id == project_id).delete()
db.delete(p)
db.commit()
return {"message": "项目已删除"}
@router.post("/{project_id}/complete")
def complete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:complete"))
):
"""Owner 手动确认项目完成"""
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
from datetime import date as date_today
p.status = ProjectStatus.COMPLETED
p.actual_completion_date = date_today.today()
db.commit()
return {"message": "项目已标记为完成", "project_id": project_id}
# ──────────────────── 里程碑管理 ────────────────────
@router.get("/{project_id}/milestones", response_model=List[MilestoneOut])
def list_milestones(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
ms = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == project_id
).order_by(ProjectMilestone.phase, ProjectMilestone.sort_order).all()
return [_build_milestone_out(m) for m in ms]
@router.post("/{project_id}/milestones", response_model=MilestoneOut)
def add_milestone(
project_id: int,
req: MilestoneCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(status_code=404, detail="项目不存在")
# 找到同阶段最大 sort_order
max_order = db.query(sa_func.max(ProjectMilestone.sort_order)).filter(
ProjectMilestone.project_id == project_id,
ProjectMilestone.phase == PhaseGroup(req.phase),
).scalar() or 0
m = ProjectMilestone(
project_id=project_id,
name=req.name,
phase=PhaseGroup(req.phase),
sort_order=max_order + 1,
estimated_days=req.estimated_days,
)
db.add(m)
db.commit()
db.refresh(m)
return _build_milestone_out(m)
@router.put("/milestones/{milestone_id}/toggle")
def toggle_milestone(
milestone_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
if not m:
raise HTTPException(status_code=404, detail="里程碑不存在")
m.is_completed = 0 if m.is_completed else 1
m.completed_at = datetime.now() if m.is_completed else None
db.commit()
return {"id": m.id, "is_completed": bool(m.is_completed)}
@router.put("/milestones/{milestone_id}")
def update_milestone(
milestone_id: int,
req: MilestoneUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
if not m:
raise HTTPException(status_code=404, detail="里程碑不存在")
if req.estimated_days is not None:
m.estimated_days = req.estimated_days
if req.start_date is not None:
m.start_date = req.start_date
db.commit()
db.refresh(m)
return _build_milestone_out(m)
@router.delete("/milestones/{milestone_id}")
def delete_milestone(
milestone_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("project:edit"))
):
m = db.query(ProjectMilestone).filter(ProjectMilestone.id == milestone_id).first()
if not m:
raise HTTPException(status_code=404, detail="里程碑不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}