325 lines
9.7 KiB
Python
325 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
|
|
|
|
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(timezone.utc).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
|