"""共享任务持久化规范。""" 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