1640 lines
52 KiB
Python
1640 lines
52 KiB
Python
"""
|
|
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})
|