- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算) - 产出时长改为必填项(非前期内容必须 > 0) - 前端+后端双重验证,防止提交0秒产出 - 删除"无产出秒数的工作可填 0"过时提示 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
432 lines
16 KiB
Python
432 lines
16 KiB
Python
"""项目管理路由"""
|
||
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, ContentType,
|
||
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
|
||
|
||
# 中期产出(动画制作)— 对标目标时长
|
||
animation_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
|
||
|
||
# 修补镜头(后期秒数,归入制作目标对比)
|
||
shot_repair_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||
Submission.project_id == p.id,
|
||
Submission.content_type == ContentType.SHOT_REPAIR,
|
||
Submission.total_seconds > 0,
|
||
).scalar() or 0
|
||
|
||
# 后期产出:按内容类型分组
|
||
post_rows = db.query(
|
||
Submission.content_type,
|
||
sa_func.sum(Submission.total_seconds).label("secs"),
|
||
).filter(
|
||
Submission.project_id == p.id,
|
||
Submission.total_seconds > 0,
|
||
Submission.project_phase == PhaseGroup.POST,
|
||
).group_by(Submission.content_type).all()
|
||
|
||
post_secs = sum(row.secs for row in post_rows)
|
||
post_breakdown = [
|
||
{"type": ct.value if hasattr(ct, 'value') else ct, "seconds": round(secs, 1)}
|
||
for ct, secs in post_rows
|
||
]
|
||
|
||
target = p.target_total_seconds
|
||
progress = round(animation_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(animation_secs, 1),
|
||
"shot_repair_seconds": round(shot_repair_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,
|
||
Submission.project_phase == PhaseGroup.PRODUCTION,
|
||
).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),
|
||
animation_seconds=round(animation_secs, 1),
|
||
post_production_seconds=round(post_secs, 1),
|
||
post_production_breakdown=post_breakdown,
|
||
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": "已删除"}
|