seaislee1209 f9016ab2af
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m4s
Build and Deploy Web / build-and-deploy (push) Successful in 17m17s
feat: 项目详情显示修补镜头 + 产出时长必填验证
- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算)
- 产出时长改为必填项(非前期内容必须 > 0)
- 前端+后端双重验证,防止提交0秒产出
- 删除"无产出秒数的工作可填 0"过时提示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 22:41:20 +08:00

432 lines
16 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, 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": "已删除"}