PMFlow/backend/app/services/task_persistence.py

326 lines
9.7 KiB
Python

"""共享任务持久化规范。"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import SkillTag, Task
from ..utils.title import compact_title
from . import workload_service
from .operation_log_service import log_operation
from ..utils.timezone import CN_TZ
TASK_PRIORITY_MAP = {
"p0": "high",
"critical": "high",
"high": "high",
"p1": "medium",
"medium": "medium",
"normal": "medium",
"default": "medium",
"p2": "low",
"p3": "low",
"low": "low",
}
TASK_STATUS_MAP = {
"pending": "todo",
"todo": "todo",
"open": "todo",
"new": "todo",
"in_progress": "in_progress",
"doing": "in_progress",
"processing": "in_progress",
"review": "review",
"qa": "review",
"testing": "review",
"done": "done",
"completed": "done",
"blocked": "blocked",
"closed": "closed",
}
SUPPORTED_TASK_STATUSES = {"todo", "in_progress", "review", "done", "blocked", "closed"}
@dataclass(slots=True)
class SkillTagResolution:
input_names: list[str]
matched_ids: list[int]
matched_items: list[dict[str, Any]]
unmatched_names: list[str]
@dataclass(slots=True)
class TaskCreateDraft:
title: str
description: Optional[str] = None
assignee_id: Optional[int] = None
required_skill_tag_ids: list[int] = field(default_factory=list)
priority: str = "medium"
status: str = "todo"
estimated_hours: Optional[float] = None
due_date: Optional[datetime] = None
actual_hours: Optional[float] = None
quality_score: Optional[float] = None
ai_recommended_assignees: Optional[dict[str, Any]] = None
def normalize_task_priority(value: Any, *, default: str = "medium") -> str:
key = str(value or default).strip().lower()
return TASK_PRIORITY_MAP.get(key, TASK_PRIORITY_MAP.get(default, "medium"))
def normalize_task_status(value: Any, *, default: str = "todo") -> str:
key = str(value or default).strip().lower()
normalized = TASK_STATUS_MAP.get(key, TASK_STATUS_MAP.get(default, "todo"))
if normalized not in SUPPORTED_TASK_STATUSES:
return default
return normalized
def normalize_estimated_hours(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
hours = round(float(value), 2)
except (TypeError, ValueError) as exc:
raise ValueError("invalid estimated_hours") from exc
if hours < 0 or hours > 999.99:
raise ValueError("invalid estimated_hours")
return hours
def normalize_due_date(value: Any) -> Optional[datetime]:
if value is None or value == "":
return None
if isinstance(value, datetime):
if value.tzinfo is not None:
return value.astimezone(CN_TZ).replace(tzinfo=None)
return value
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise ValueError("invalid due_date") from exc
def normalize_int_list(values: Any) -> list[int]:
if values is None:
return []
if not isinstance(values, (list, tuple, set)):
values = [values]
normalized: list[int] = []
seen: set[int] = set()
for raw in values:
if isinstance(raw, bool):
continue
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
continue
if value <= 0 or value in seen:
continue
seen.add(value)
normalized.append(value)
return normalized
def normalize_skill_names(values: Any) -> list[str]:
if values is None:
return []
if not isinstance(values, (list, tuple, set)):
values = [values]
normalized: list[str] = []
seen: set[str] = set()
for raw in values:
text = str(raw or "").strip()
if not text:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
normalized.append(text[:120])
return normalized
def build_task_create_draft(
*,
title: str,
description: Optional[str] = None,
assignee_id: Optional[int] = None,
required_skill_tag_ids: Any = None,
priority: Any = "medium",
status: Any = "todo",
estimated_hours: Any = None,
due_date: Any = None,
actual_hours: Any = None,
quality_score: Any = None,
ai_recommended_assignees: Optional[dict[str, Any]] = None,
) -> TaskCreateDraft:
normalized_title = compact_title(str(title or "").strip(), max_len=32)
if not normalized_title:
raise ValueError("task title is empty")
return TaskCreateDraft(
title=normalized_title,
description=str(description or "").strip() or None,
assignee_id=int(assignee_id) if assignee_id is not None else None,
required_skill_tag_ids=normalize_int_list(required_skill_tag_ids),
priority=normalize_task_priority(priority),
status=normalize_task_status(status),
estimated_hours=normalize_estimated_hours(estimated_hours),
due_date=normalize_due_date(due_date),
actual_hours=normalize_estimated_hours(actual_hours),
quality_score=None if quality_score in {None, ""} else float(quality_score),
ai_recommended_assignees=ai_recommended_assignees,
)
async def resolve_skill_tag_ids_by_names(
db: AsyncSession,
*,
tenant_id: int,
skill_names: Any,
) -> SkillTagResolution:
input_names = normalize_skill_names(skill_names)
if not input_names:
return SkillTagResolution(
input_names=[],
matched_ids=[],
matched_items=[],
unmatched_names=[],
)
rows = (
await db.execute(
select(SkillTag).where(SkillTag.tenant_id == int(tenant_id))
)
).scalars().all()
if not rows:
return SkillTagResolution(
input_names=input_names,
matched_ids=[],
matched_items=[],
unmatched_names=input_names,
)
exact: dict[str, SkillTag] = {}
fuzzy: list[tuple[str, SkillTag]] = []
for row in rows:
name = str(row.name or "").strip().lower()
slug = str(row.slug or "").strip().lower()
if name:
exact[name] = row
fuzzy.append((name, row))
if slug:
exact[slug] = row
fuzzy.append((slug, row))
matched_ids: list[int] = []
matched_items: list[dict[str, Any]] = []
unmatched_names: list[str] = []
seen_ids: set[int] = set()
for name in input_names:
key = name.lower()
matched = exact.get(key)
if matched is None:
for candidate, row in fuzzy:
if key in candidate or candidate in key:
matched = row
break
if matched is None:
unmatched_names.append(name)
continue
skill_id = int(matched.id)
if skill_id not in seen_ids:
seen_ids.add(skill_id)
matched_ids.append(skill_id)
matched_items.append(
{
"input_name": name,
"skill_tag_id": skill_id,
"skill_tag_name": str(matched.name or "").strip(),
"skill_tag_slug": str(matched.slug or "").strip(),
}
)
return SkillTagResolution(
input_names=input_names,
matched_ids=matched_ids,
matched_items=matched_items,
unmatched_names=unmatched_names,
)
async def persist_task(
db: AsyncSession,
*,
tenant_id: int,
truth_node_id: int,
project_id: int,
draft: TaskCreateDraft,
actor_user_id: Optional[int] = None,
action: str = "task.create",
remark: Optional[str] = None,
commit: bool = False,
refresh_workload: bool = False,
) -> Task:
task = Task(
tenant_id=int(tenant_id),
truth_node_id=int(truth_node_id),
project_id=int(project_id),
assignee_id=draft.assignee_id,
title=draft.title,
description=draft.description,
required_skill_tag_ids=draft.required_skill_tag_ids,
status=draft.status,
priority=draft.priority,
estimated_hours=draft.estimated_hours,
actual_hours=draft.actual_hours,
quality_score=draft.quality_score,
due_date=draft.due_date,
ai_recommended_assignees=draft.ai_recommended_assignees,
)
db.add(task)
await db.flush()
if actor_user_id is not None:
await log_operation(
db,
tenant_id=int(tenant_id),
user_id=int(actor_user_id),
project_id=int(project_id),
entity_type="task",
entity_id=str(task.id),
action=action,
before_data=None,
after_data={
"title": task.title,
"truth_node_id": int(truth_node_id),
"priority": task.priority,
"status": task.status,
"assignee_id": int(task.assignee_id) if task.assignee_id is not None else None,
"required_skill_tag_ids": list(task.required_skill_tag_ids or []),
"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,
},
remark=remark,
)
if refresh_workload and task.assignee_id is not None:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=int(tenant_id),
user_id=int(task.assignee_id),
)
elif commit:
await db.commit()
return task