""" SSTC 唯一事实链路 API 覆盖模块: - 项目概览/链路导航/风险预警 - 信号源 - 需求宪章(含对齐) - 任务拆解与执行 - 发布验证 - 复盘沉淀 """ from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..database import get_db from ..models import ( AICallLog, AlignmentRecord, ProjectSignalLink, Signal, Task, TruthNode, User, UserProject, ) from ..schemas.sstc import ( AlignmentConfirmRequest, AlignmentStartRequest, CharterUpdateRequest, ReleaseCreateRequest, ReleaseStatusPatchRequest, ReviewArchiveRequest, ReviewCreateRequest, TaskStatusPatchRequest, ) from ..services.llm import llm_service from ..services.operation_log_service import log_operation from ..services.auth import has_any_role from ..services.skill_service import recommend_personnel from ..services import skill_score_service from ..services.sstc_service import ( ALIGNED_STATUSES, DONE_STATUSES, RELEASE_VERIFIED_STATUSES, SSTCService, ) from ..services import workload_service from ..dependencies import get_current_user, require_project_member_or_manager from ..utils.title import compact_title router = APIRouter(tags=["sstc"]) def _parse_project_id(project_id: str) -> int: try: return int(project_id) except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid project_id") from exc def _parse_node_id(node_id: str, label: str) -> int: try: return int(node_id) except ValueError as exc: raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc def _as_dict(value: Any) -> dict: if isinstance(value, dict): return value if isinstance(value, str): import json try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {"text": value} except (TypeError, ValueError): return {"text": value} return {} def _parse_due_date(value: Optional[str]) -> Optional[datetime]: if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid due_date") from exc async def _ensure_project( svc: SSTCService, project_id: int, ): project = await svc.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return project async def _ensure_charter( db: AsyncSession, *, tenant_id: int, project_id: int, charter_id: int, ) -> TruthNode: result = await db.execute( select(TruthNode).where( and_( TruthNode.id == charter_id, TruthNode.project_id == project_id, TruthNode.tenant_id == tenant_id, TruthNode.node_type == "charter", ) ) ) charter = result.scalar_one_or_none() if not charter: raise HTTPException(status_code=404, detail="Charter not found") return charter async def _ensure_task( db: AsyncSession, *, tenant_id: int, project_id: int, task_id: int, ) -> Task: result = await db.execute( select(Task).where( and_( Task.id == task_id, Task.project_id == project_id, Task.tenant_id == tenant_id, ) ) ) task = result.scalar_one_or_none() if not task: raise HTTPException(status_code=404, detail="Task not found") return task async def _ensure_release( db: AsyncSession, *, tenant_id: int, project_id: int, release_node_id: int, ) -> TruthNode: result = await db.execute( select(TruthNode).where( and_( TruthNode.id == release_node_id, TruthNode.project_id == project_id, TruthNode.tenant_id == tenant_id, TruthNode.node_type == "release", ) ) ) release_node = result.scalar_one_or_none() if not release_node: raise HTTPException(status_code=404, detail="Release plan not found") return release_node def _assert_charter_aligned(charter: TruthNode) -> None: if (charter.status or "").lower() not in ALIGNED_STATUSES: raise HTTPException( status_code=409, detail="宪章未对齐,任务环节未解锁", ) def _is_manager(user: User) -> bool: return has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]) def _require_manager(user: User) -> None: if not _is_manager(user): raise HTTPException(status_code=403, detail="仅项目负责人或管理员可执行该操作") @router.get("/projects/{project_id}/sstc/overview") async def get_board_overview( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) overview = await svc.build_overview(pid) stages = await svc.build_stages(pid) risks = await svc.build_risks(pid) return success_response( { "overview": overview, "stages": stages, "risks": risks, } ) @router.get("/projects/{project_id}/sstc/stages") async def get_stages( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) return success_response(await svc.build_stages(pid)) @router.get("/projects/{project_id}/sstc/risks") async def get_risks( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) return success_response(await svc.build_risks(pid)) @router.get("/projects/{project_id}/sstc-chain") async def get_sstc_chain( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) return success_response(await svc.build_chain(pid)) @router.get("/projects/{project_id}/signals") async def list_signals( project_id: str, status: Optional[str] = Query(default=None, description="pending/linked/archived"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) signals = await svc.get_signals(pid) charters = await svc.get_charters(pid) linked_signal_ids: set[int] = set() for c in charters: payload = _as_dict(c.content) for sid in payload.get("signal_ids", []): if isinstance(sid, int): linked_signal_ids.add(sid) rows = [] for s in signals: row_status = "pending" if int(s.id) in linked_signal_ids: row_status = "linked" if bool(s.processed): row_status = "archived" if status and row_status != status: continue rows.append( { "id": str(s.id), "title": (s.content or "")[:80] or "未命名信号", "content": s.content, "priority": s.priority, "signal_type": s.source or "other", "source": s.source, "status": row_status, "created_at": s.created_at.isoformat() if s.created_at else None, } ) return success_response(rows) @router.post("/projects/{project_id}/signals/{signal_id}/archive") async def archive_signal( project_id: str, signal_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user pid = _parse_project_id(project_id) sid = _parse_node_id(signal_id, "signal_id") svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) result = await db.execute( select(Signal).where( and_(Signal.id == sid, Signal.tenant_id == int(current_user.tenant_id)) ) ) signal = result.scalar_one_or_none() if not signal: raise HTTPException(status_code=404, detail="Signal not found") before = {"processed": signal.processed} signal.processed = True await log_operation( db, tenant_id=int(current_user.tenant_id), user_id=int(current_user.id), project_id=pid, entity_type="signal", entity_id=str(sid), action="archive", before_data=before, after_data={"processed": True}, ) await db.commit() return success_response({"id": str(signal.id), "status": "archived"}) class CharterCreateRequest(BaseModel): title: str content: dict[str, Any] | None = None signal_ids: list[int] @router.get("/projects/{project_id}/charters") async def list_charters( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) charters = await svc.get_charters(pid) payload = [ { "id": str(c.id), "title": c.title, "content": _as_dict(c.content), "signal_ids": _as_dict(c.content).get("signal_ids", []), "status": c.status, "version": c.version, "parent_id": str(c.parent_id) if c.parent_id else None, "created_at": c.created_at.isoformat() if c.created_at else None, } for c in charters ] return success_response(payload) @router.post("/projects/{project_id}/charters") async def create_charter( project_id: str, body: CharterCreateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) if not body.signal_ids: raise HTTPException(status_code=400, detail="宪章必须关联至少一个信号") pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) await _ensure_project(svc, pid) link_result = await db.execute( select(ProjectSignalLink.signal_id).where( and_( ProjectSignalLink.project_id == pid, ProjectSignalLink.signal_id.in_(body.signal_ids), ProjectSignalLink.tenant_id == tenant_id, ) ) ) valid_signal_ids = {int(v) for v in link_result.scalars().all()} if set(body.signal_ids) - valid_signal_ids: raise HTTPException(status_code=400, detail="部分信号未关联到当前项目") content = dict(body.content or {}) content["signal_ids"] = sorted(set(body.signal_ids)) charter = TruthNode( node_type="charter", parent_id=None, project_id=pid, created_by=int(current_user.id), title=compact_title(body.title, max_len=24), content=content, version=1, status="draft", tenant_id=tenant_id, ) db.add(charter) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="charter", entity_id="new", action="create", after_data={"title": charter.title, "signal_ids": content["signal_ids"]}, ) await db.commit() await db.refresh(charter) return success_response( { "id": str(charter.id), "title": charter.title, "content": _as_dict(charter.content), "signal_ids": _as_dict(charter.content).get("signal_ids", []), "status": charter.status, "version": charter.version, } ) @router.get("/projects/{project_id}/charters/{charter_id}") async def get_charter_detail( project_id: str, charter_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) await _ensure_project(svc, pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) content = _as_dict(charter.content) signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)] signal_rows = [] if signal_ids: signal_result = await db.execute( select(Signal).where( and_(Signal.id.in_(signal_ids), Signal.tenant_id == tenant_id) ) ) signal_rows = list(signal_result.scalars().all()) task_result = await db.execute( select(Task).where( and_( Task.truth_node_id == cid, Task.project_id == pid, Task.tenant_id == tenant_id, ) ) ) tasks = list(task_result.scalars().all()) align_result = await db.execute( select(AlignmentRecord).where( and_( AlignmentRecord.truth_node_id == cid, AlignmentRecord.tenant_id == tenant_id, ) ) ) alignments = list(align_result.scalars().all()) return success_response( { "id": str(charter.id), "title": charter.title, "content": content, "signal_ids": signal_ids, "signals": [ { "id": str(s.id), "source": s.source, "content": s.content, "priority": s.priority, "impact_score": s.impact_score, } for s in signal_rows ], "tasks": [ { "id": str(t.id), "title": t.title, "description": t.description, "status": t.status, "priority": t.priority, "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, "assignee_id": str(t.assignee_id) if t.assignee_id else None, "due_date": t.due_date.isoformat() if t.due_date else None, } for t in tasks ], "alignment_records": [ { "user_id": str(r.user_id), "score": r.alignment_score, "confirmed": bool(r.is_confirmed), "comments": r.comments, } for r in alignments ], "status": charter.status, "version": charter.version, } ) @router.patch("/projects/{project_id}/charters/{charter_id}") async def update_charter( project_id: str, charter_id: str, body: CharterUpdateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) before = { "title": charter.title, "content": _as_dict(charter.content), "version": charter.version, "status": charter.status, } aligned = (charter.status or "").lower() in ALIGNED_STATUSES if aligned and body.force_new_version: new_node = TruthNode( node_type="charter", parent_id=int(charter.id), project_id=pid, created_by=int(current_user.id), title=compact_title((body.title or charter.title), max_len=24), content=body.content if body.content is not None else _as_dict(charter.content), version=int(charter.version or 1) + 1, status="draft", tenant_id=tenant_id, ) db.add(new_node) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="charter", entity_id=str(charter.id), action="new_version", before_data=before, after_data={"new_charter_id": "pending"}, ) await db.commit() await db.refresh(new_node) return success_response( { "id": str(new_node.id), "version": new_node.version, "status": new_node.status, "title": new_node.title, "content": _as_dict(new_node.content), "new_version_created": True, } ) if body.title is not None: charter.title = compact_title(body.title, max_len=24) if body.content is not None: charter.content = body.content charter.status = "draft" await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="charter", entity_id=str(charter.id), action="update", before_data=before, after_data={ "title": charter.title, "content": _as_dict(charter.content), "version": charter.version, "status": charter.status, }, ) await db.commit() await db.refresh(charter) return success_response( { "id": str(charter.id), "title": charter.title, "content": _as_dict(charter.content), "status": charter.status, "version": charter.version, "new_version_created": False, } ) @router.post("/projects/{project_id}/charters/{charter_id}/alignment/start") async def start_alignment( project_id: str, charter_id: str, body: AlignmentStartRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) before = {"status": charter.status} charter.status = "aligning" await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="charter", entity_id=str(cid), action="start_alignment", before_data=before, after_data={"status": charter.status, "note": body.note}, ) await db.commit() return success_response({"id": str(cid), "status": charter.status}) @router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm") async def confirm_alignment( project_id: str, charter_id: str, body: AlignmentConfirmRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) await _ensure_project(svc, pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) row_result = await db.execute( select(AlignmentRecord).where( and_( AlignmentRecord.truth_node_id == cid, AlignmentRecord.user_id == int(current_user.id), AlignmentRecord.tenant_id == tenant_id, ) ) ) row = row_result.scalar_one_or_none() if row is None: row = AlignmentRecord( truth_node_id=cid, user_id=int(current_user.id), alignment_score=body.score, is_confirmed=body.confirmed, comments=body.comments, tenant_id=tenant_id, ) db.add(row) else: row.alignment_score = body.score row.is_confirmed = body.confirmed row.comments = body.comments member_ids = await svc.get_project_member_ids(pid) confirmed_map: set[int] = set() rows_result = await db.execute( select(AlignmentRecord).where( and_( AlignmentRecord.truth_node_id == cid, AlignmentRecord.tenant_id == tenant_id, AlignmentRecord.is_confirmed.is_(True), ) ) ) for r in rows_result.scalars().all(): confirmed_map.add(int(r.user_id)) all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False if all_confirmed: charter.status = "aligned" elif (charter.status or "").lower() not in {"aligned"}: charter.status = "aligning" await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="alignment", entity_id=f"{cid}:{current_user.id}", action="confirm", after_data={ "score": body.score, "confirmed": body.confirmed, "all_confirmed": all_confirmed, "charter_status": charter.status, }, ) await db.commit() return success_response( { "charter_id": str(cid), "charter_status": charter.status, "all_confirmed": all_confirmed, "confirmed_count": len(confirmed_map), "member_count": len(member_ids), } ) class TaskCreateRequest(BaseModel): title: str description: Optional[str] = None required_skill_tag_ids: Optional[list[int]] = None priority: str = "medium" estimated_hours: Optional[float] = None due_date: Optional[str] = None class TaskUpdateRequest(BaseModel): title: Optional[str] = None description: Optional[str] = None assignee_id: Optional[int] = None required_skill_tag_ids: Optional[list[int]] = None priority: Optional[str] = None estimated_hours: Optional[float] = None due_date: Optional[str] = None @router.post("/projects/{project_id}/charters/{charter_id}/tasks") async def create_task( project_id: str, charter_id: str, body: TaskCreateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) _assert_charter_aligned(charter) due_date = _parse_due_date(body.due_date) task = Task( tenant_id=tenant_id, truth_node_id=cid, project_id=pid, assignee_id=None, title=compact_title(body.title, max_len=32), description=body.description, required_skill_tag_ids=body.required_skill_tag_ids or [], status="todo", priority=body.priority, estimated_hours=body.estimated_hours, due_date=due_date, ) db.add(task) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id="new", action="create", after_data={ "title": task.title, "truth_node_id": cid, "priority": body.priority, }, ) await db.commit() await db.refresh(task) return success_response( { "id": str(task.id), "title": task.title, "status": task.status, "priority": task.priority, "charter_id": str(cid), } ) @router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") async def ai_breakdown_tasks( project_id: str, charter_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) _assert_charter_aligned(charter) prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组: [ {{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}} ] 宪章标题:{charter.title} 宪章内容:{_as_dict(charter.content)} """ try: rows = await llm_service.generate_task_breakdown_async( prompt, db=db, tenant_id=tenant_id, project_id=pid, ) except RuntimeError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc created = [] prompt_tokens = max(len(prompt) // 4, 1) completion_tokens = 0 for item in rows or []: title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) if not title: continue priority = str(item.get("priority") or "P1").lower() mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low" task = Task( tenant_id=tenant_id, truth_node_id=cid, project_id=pid, assignee_id=None, title=title, description=item.get("description"), required_skill_tag_ids=[], status="todo", priority=mapped_priority, estimated_hours=float(item.get("estimated_hours") or 0) or None, ) db.add(task) created.append(task) completion_tokens += max(len(title) // 2, 1) db.add( AICallLog( tenant_id=tenant_id, user_id=int(current_user.id), function_name="sstc_ai_breakdown", prompt=prompt, response=f"created_count={len(created)}", tokens_used=prompt_tokens + completion_tokens, cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000), status="success", ) ) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id=str(cid), action="ai_breakdown", after_data={"created_count": len(created)}, ) await db.commit() return success_response( { "created_count": len(created), "items": [ {"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status} for t in created ], } ) @router.get("/projects/{project_id}/tasks/board") async def get_task_board( project_id: str, assignee_id: Optional[int] = Query(default=None), priority: Optional[str] = Query(default=None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) result = await db.execute( select(Task).where( and_( Task.project_id == pid, Task.tenant_id == tenant_id, ) ) ) tasks = list(result.scalars().all()) if assignee_id is not None: tasks = [t for t in tasks if t.assignee_id == assignee_id] if priority: tasks = [t for t in tasks if (t.priority or "").lower() == priority.lower()] rows = [] for t in tasks: risk_count = 0 if (t.status or "").lower() == "blocked": risk_count += 1 if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES: risk_count += 1 rows.append( { "id": str(t.id), "title": t.title, "description": t.description, "status": t.status, "priority": t.priority, "assignee_id": str(t.assignee_id) if t.assignee_id else None, "due_date": t.due_date.isoformat() if t.due_date else None, "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, "risk_count": risk_count, "truth_node_id": str(t.truth_node_id), } ) return success_response(rows) @router.patch("/projects/{project_id}/tasks/{task_id}/status") async def patch_task_status( project_id: str, task_id: str, body: TaskStatusPatchRequest, background_tasks: BackgroundTasks, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user pid = _parse_project_id(project_id) tid = _parse_node_id(task_id, "task_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) if not _is_manager(current_user) and int(task.assignee_id or 0) != int(current_user.id): raise HTTPException(status_code=403, detail="仅负责人或任务执行人可更新状态") before_status = (task.status or "").lower() before = {"status": task.status} task.status = body.status if body.status in DONE_STATUSES: task.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id=str(tid), action="status_change", before_data=before, after_data={"status": body.status}, ) if task.assignee_id: await workload_service.refresh_user_assigned_hours_from_tasks( db, tenant_id=tenant_id, user_id=int(task.assignee_id), ) else: await db.commit() became_done = (body.status or "").lower() in DONE_STATUSES and before_status not in DONE_STATUSES if became_done and task.assignee_id: background_tasks.add_task( skill_score_service.process_task_completion, task_id=int(task.id), tenant_id=tenant_id, operator_user_id=int(current_user.id), ) return success_response({"id": str(task.id), "status": task.status}) @router.patch("/projects/{project_id}/tasks/{task_id}") async def patch_task( project_id: str, task_id: str, body: TaskUpdateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) tid = _parse_node_id(task_id, "task_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) before = { "title": task.title, "description": task.description, "assignee_id": task.assignee_id, "priority": task.priority, "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, "due_date": task.due_date.isoformat() if task.due_date else None, } old_assignee_id = int(task.assignee_id) if task.assignee_id else None if body.title is not None: task.title = compact_title(body.title, max_len=32) if body.description is not None: task.description = body.description if body.assignee_id is not None: task.assignee_id = body.assignee_id if body.required_skill_tag_ids is not None: task.required_skill_tag_ids = body.required_skill_tag_ids if body.priority is not None: task.priority = body.priority if body.estimated_hours is not None: task.estimated_hours = body.estimated_hours if body.due_date is not None: task.due_date = _parse_due_date(body.due_date) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id=str(tid), action="update", before_data=before, after_data={ "title": task.title, "description": task.description, "assignee_id": task.assignee_id, "priority": task.priority, "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, "due_date": task.due_date.isoformat() if task.due_date else None, }, ) touched_user_ids: set[int] = set() if old_assignee_id: touched_user_ids.add(old_assignee_id) if task.assignee_id: touched_user_ids.add(int(task.assignee_id)) if touched_user_ids: for uid in touched_user_ids: await workload_service.refresh_user_assigned_hours_from_tasks( db, tenant_id=tenant_id, user_id=uid, ) else: await db.commit() return success_response({"id": str(task.id), "status": task.status}) @router.delete("/projects/{project_id}/tasks/{task_id}") async def delete_task( project_id: str, task_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) tid = _parse_node_id(task_id, "task_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) assignee_id = int(task.assignee_id) if task.assignee_id else None await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id=str(tid), action="delete", before_data={ "assignee_id": assignee_id, "estimated_hours": float(task.estimated_hours) if task.estimated_hours else None, "status": task.status, }, ) await db.execute( delete(Task).where( and_( Task.id == tid, Task.project_id == pid, Task.tenant_id == tenant_id, ) ) ) if assignee_id: await workload_service.refresh_user_assigned_hours_from_tasks( db, tenant_id=tenant_id, user_id=assignee_id, ) else: await db.commit() return success_response({"id": str(tid), "deleted": True}) @router.post("/projects/{project_id}/tasks/{task_id}/recommend-assignee") async def recommend_assignee_for_task( project_id: str, task_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user pid = _parse_project_id(project_id) tid = _parse_node_id(task_id, "task_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task = await _ensure_task(db, tenant_id=tenant_id, project_id=pid, task_id=tid) required_skills = [str(x) for x in (task.required_skill_tag_ids or [])] recommendations = await recommend_personnel( db=db, tenant_id=tenant_id, required_skills=required_skills, task_description=task.description or task.title, task_id=str(task.id), limit=5, ) project_user_ids_result = await db.execute( select(UserProject.user_id).where( and_( UserProject.tenant_id == tenant_id, UserProject.project_id == pid, ) ) ) project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()} active_users_result = await db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), ) ) ) active_user_ids = {int(uid) for uid in active_users_result.scalars().all()} filtered = [] for item in recommendations.get("recommendations", []): try: uid = int(item.get("user_id")) except (TypeError, ValueError): continue if uid in project_user_ids and uid in active_user_ids: filtered.append(item) recommendations["recommendations"] = filtered recommendations["total_count"] = len(filtered) return success_response(recommendations) @router.post("/projects/{project_id}/tasks/optimize-workload") async def optimize_task_workload( project_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) task_result = await db.execute( select(Task).where( and_(Task.project_id == pid, Task.tenant_id == tenant_id, Task.status.in_(["todo", "in_progress"])) ) ) tasks = list(task_result.scalars().all()) wl_result = await db.execute( select(User.id, User.workload) .join( UserProject, and_( UserProject.user_id == User.id, UserProject.tenant_id == tenant_id, UserProject.project_id == pid, ), ) .where( and_( User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), ) ) ) members = [{"id": int(uid), "workload": int(wl or 0)} for uid, wl in wl_result.all()] if not members: return success_response({"updated": 0, "message": "no active users"}) members.sort(key=lambda x: x["workload"]) updated = 0 touched_user_ids: set[int] = set() for task in tasks: if task.assignee_id: continue picked = members[0] task.assignee_id = int(picked["id"]) touched_user_ids.add(int(picked["id"])) picked["workload"] += int(float(task.estimated_hours or 1) * 2) members.sort(key=lambda x: x["workload"]) updated += 1 await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="task", entity_id=str(pid), action="optimize_workload", after_data={"updated": updated}, ) if touched_user_ids: for uid in touched_user_ids: await workload_service.refresh_user_assigned_hours_from_tasks( db, tenant_id=tenant_id, user_id=uid, ) else: await db.commit() return success_response({"updated": updated}) @router.get("/projects/{project_id}/releases") async def list_releases( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) releases = await svc.get_releases(pid) return success_response( [ { "id": str(r.id), "title": r.title, "status": r.status, "content": _as_dict(r.content), "version": r.version, "created_at": r.created_at.isoformat() if r.created_at else None, } for r in releases ] ) @router.post("/projects/{project_id}/releases") async def create_release_plan( project_id: str, body: ReleaseCreateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) svc = SSTCService(db, tenant_id=tenant_id) await _ensure_project(svc, pid) charter = await _ensure_charter( db, tenant_id=tenant_id, project_id=pid, charter_id=int(body.charter_id), ) if not body.task_ids: raise HTTPException(status_code=400, detail="发布计划必须绑定至少一个任务") task_result = await db.execute( select(Task).where( and_( Task.id.in_(body.task_ids), Task.project_id == pid, Task.tenant_id == tenant_id, ) ) ) tasks = list(task_result.scalars().all()) if len(tasks) != len(body.task_ids): raise HTTPException(status_code=400, detail="部分任务不存在") not_done = [t for t in tasks if (t.status or "").lower() not in DONE_STATUSES] if not_done: raise HTTPException(status_code=409, detail="发布计划仅允许绑定已完成任务") content = { "charter_id": int(charter.id), "task_ids": [int(t.id) for t in tasks], "planned_at": body.planned_at, "verification_items": [], "verification_done": False, } release_node = TruthNode( tenant_id=tenant_id, node_type="release", parent_id=int(charter.id), project_id=pid, created_by=int(current_user.id), title=compact_title(body.title, max_len=24), content=content, version=1, status="pending", ) db.add(release_node) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="release", entity_id="new", action="create", after_data={"title": release_node.title, "task_ids": body.task_ids}, ) await db.commit() await db.refresh(release_node) return success_response( { "id": str(release_node.id), "title": release_node.title, "status": release_node.status, "content": _as_dict(release_node.content), } ) @router.patch("/projects/{project_id}/releases/{release_node_id}/status") async def patch_release_status( project_id: str, release_node_id: str, body: ReleaseStatusPatchRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) rid = _parse_node_id(release_node_id, "release_node_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) release_node = await _ensure_release( db, tenant_id=tenant_id, project_id=pid, release_node_id=rid ) before = {"status": release_node.status} release_node.status = body.status content = _as_dict(release_node.content) content["status_note"] = body.note release_node.content = content await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="release", entity_id=str(rid), action="status_change", before_data=before, after_data={"status": body.status, "note": body.note}, ) await db.commit() return success_response({"id": str(release_node.id), "status": release_node.status}) @router.post("/projects/{project_id}/releases/{release_node_id}/verification/generate") async def generate_release_verification( project_id: str, release_node_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) rid = _parse_node_id(release_node_id, "release_node_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) release_node = await _ensure_release( db, tenant_id=tenant_id, project_id=pid, release_node_id=rid ) content = _as_dict(release_node.content) charter_id = int(content.get("charter_id") or 0) charter = await _ensure_charter( db, tenant_id=tenant_id, project_id=pid, charter_id=charter_id, ) charter_content = _as_dict(charter.content) success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] if not success_metrics: success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] items = [ { "id": f"check-{i+1}", "title": str(metric), "expected": "达到预期", "status": "pending", "verified_by": None, "verified_at": None, } for i, metric in enumerate(success_metrics) ] content["verification_items"] = items release_node.content = content await db.commit() return success_response({"release_id": str(release_node.id), "verification_items": items}) @router.post("/projects/{project_id}/releases/{release_node_id}/verification/complete") async def complete_release_verification( project_id: str, release_node_id: str, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) rid = _parse_node_id(release_node_id, "release_node_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) release_node = await _ensure_release( db, tenant_id=tenant_id, project_id=pid, release_node_id=rid ) content = _as_dict(release_node.content) items = content.get("verification_items", []) now_str = datetime.now(timezone.utc).isoformat() for item in items: item["status"] = "done" item["verified_by"] = str(current_user.id) item["verified_at"] = now_str content["verification_items"] = items content["verification_done"] = True release_node.content = content release_node.status = "verified" await db.commit() return success_response({"id": str(release_node.id), "status": release_node.status}) @router.get("/projects/{project_id}/reviews") async def list_reviews( project_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): pid = _parse_project_id(project_id) svc = SSTCService(db, tenant_id=int(current_user.tenant_id)) await _ensure_project(svc, pid) reviews = await svc.get_reviews(pid) return success_response( [ { "id": str(r.id), "title": r.title, "status": r.status, "content": _as_dict(r.content), "version": r.version, "created_at": r.created_at.isoformat() if r.created_at else None, } for r in reviews ] ) @router.post("/projects/{project_id}/reviews") async def create_review( project_id: str, body: ReviewCreateRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) release_node = await _ensure_release( db, tenant_id=tenant_id, project_id=pid, release_node_id=int(body.release_node_id), ) if (release_node.status or "").lower() not in RELEASE_VERIFIED_STATUSES: raise HTTPException(status_code=409, detail="发布未验证,复盘环节未解锁") review_node = TruthNode( tenant_id=tenant_id, node_type="review", parent_id=int(release_node.id), project_id=pid, created_by=int(current_user.id), title=compact_title(body.title, max_len=24), content=body.content, version=1, status="draft", ) db.add(review_node) await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="review", entity_id="new", action="create", after_data={"title": review_node.title, "release_node_id": body.release_node_id}, ) await db.commit() await db.refresh(review_node) return success_response( { "id": str(review_node.id), "title": review_node.title, "status": review_node.status, "content": _as_dict(review_node.content), } ) @router.post("/projects/{project_id}/reviews/{review_node_id}/archive") async def archive_review( project_id: str, review_node_id: str, body: ReviewArchiveRequest, permission_user: User = Depends(require_project_member_or_manager), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _ = permission_user _require_manager(current_user) pid = _parse_project_id(project_id) rid = _parse_node_id(review_node_id, "review_node_id") tenant_id = int(current_user.tenant_id) await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) result = await db.execute( select(TruthNode).where( and_( TruthNode.id == rid, TruthNode.project_id == pid, TruthNode.tenant_id == tenant_id, TruthNode.node_type == "review", ) ) ) review_node = result.scalar_one_or_none() if not review_node: raise HTTPException(status_code=404, detail="Review not found") before = {"status": review_node.status} review_node.status = "archived" if body.archive else "draft" content = _as_dict(review_node.content) content["archive_note"] = body.note content["archived_at"] = datetime.now(timezone.utc).isoformat() if body.archive else None review_node.content = content await log_operation( db, tenant_id=tenant_id, user_id=int(current_user.id), project_id=pid, entity_type="review", entity_id=str(rid), action="archive" if body.archive else "unarchive", before_data=before, after_data={"status": review_node.status}, ) await db.commit() return success_response({"id": str(review_node.id), "status": review_node.status})