"""项目管理路由""" 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 集数进度 episode_progress = [] ep_target = p.episode_duration_minutes * 60 # 每集目标秒数 for ep in range(1, p.episode_count + 1): ep_subs = db.query(Submission).filter( Submission.project_id == p.id, Submission.episode_number == ep, Submission.total_seconds > 0, ).all() ep_total = sum(s.total_seconds for s in ep_subs) # 每集贡献者 contributors = {} for s in ep_subs: name = s.user.name if s.user else "未知" contributors[name] = contributors.get(name, 0) + s.total_seconds 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(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": "已删除"}