"""QC domain service — the single source of truth for the QC business rules. Concepts (kept strictly separate): * QCTemplate / QCQuestion → global, reusable definition (a starting point) * TaskQCDefinition/…Question → task-specific COPY the user customizes freely * TaskQCResult → the evaluation (append-only history) done during execution Rules enforced here (and reused by routers/tasks.py + the dashboard): * A task cannot start (progress > 0) while required PRE-EXECUTION QC is unresolved/failed. * A task cannot reach 100% while ANY required QC question (pre/during/after) is unresolved. * `na` ("مطرح نیست") counts as satisfied for gating purposes. """ from __future__ import annotations import uuid from dataclasses import dataclass, field from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models import ( QCAnswer, QCQuestionPhase, Task, TaskQCDefinition, TaskQCQuestion, TaskQCResult, ) PRE = QCQuestionPhase.pre.value SATISFIED = {QCAnswer.pass_, QCAnswer.na} # --- status vocabulary -------------------------------------------------------- QC_STATUS_NONE = "none" QC_STATUS_PRE_PENDING = "pre_qc_pending" QC_STATUS_PRE_FAILED = "pre_qc_failed" QC_STATUS_READY = "ready" QC_STATUS_EXEC_PENDING = "exec_qc_pending" QC_STATUS_IN_PROGRESS = "in_progress" QC_STATUS_REWORK = "qc_failed_rework" QC_STATUS_PASSED = "final_passed" @dataclass class PhaseSummary: phase: str total: int = 0 passed: int = 0 failed: int = 0 pending: int = 0 na: int = 0 required_total: int = 0 required_unresolved: int = 0 @property def status(self) -> str: if self.total == 0: return "none" if self.failed: return "failed" if self.pending: return "pending" return "passed" def as_dict(self) -> dict: return { "total": self.total, "passed": self.passed, "failed": self.failed, "pending": self.pending, "na": self.na, "required_total": self.required_total, "required_unresolved": self.required_unresolved, "status": self.status, } @dataclass class QCEvaluation: """Latest answer of one task QC question.""" question: TaskQCQuestion latest: TaskQCResult | None @property def answer(self) -> str | None: return self.latest.answer.value if self.latest else None @property def satisfied(self) -> bool: return self.latest is not None and self.latest.answer in SATISFIED @dataclass class TaskQCState: items: list[QCEvaluation] = field(default_factory=list) @property def pre(self) -> PhaseSummary: return _summarize([i for i in self.items if i.question.phase == QCQuestionPhase.pre], "pre") @property def exec_(self) -> PhaseSummary: return _summarize([i for i in self.items if i.question.phase != QCQuestionPhase.pre], "exec") @property def all_(self) -> PhaseSummary: return _summarize(self.items, "all") @property def can_start(self) -> bool: return self.pre.required_unresolved == 0 and self.pre.failed == 0 @property def can_complete(self) -> bool: return self.all_.required_unresolved == 0 and self.all_.failed == 0 def block_reasons(self, requested_percentage: int | None = None) -> list[str]: reasons: list[str] = [] if requested_percentage is not None and requested_percentage > 0 and not self.can_start: if self.pre.failed: reasons.append("کنترل کیفیت پیش از اجرا مردود شده است (ایرادات را برطرف کنید)") elif self.pre.required_unresolved: reasons.append( f"{self.pre.required_unresolved} سؤال کنترل کیفیت پیش از اجرا پاسخ داده نشده است" ) if requested_percentage == 100 and not self.can_complete: if self.all_.failed: reasons.append("تسک دارای کنترل کیفیت مردود است و قابل بستن نیست") elif self.all_.required_unresolved: reasons.append( f"{self.all_.required_unresolved} سؤال کنترل کیفیت الزامی هنوز ارزیابی نشده‌اند" ) return reasons def status(self, progress: int) -> str: if not self.items: return QC_STATUS_NONE if self.pre.failed: return QC_STATUS_PRE_FAILED if self.pre.total > 0 and not self.can_start: return QC_STATUS_PRE_PENDING if progress == 0: return QC_STATUS_READY if self.exec_.failed: return QC_STATUS_REWORK if progress >= 100: return QC_STATUS_PASSED if self.can_complete else QC_STATUS_EXEC_PENDING return QC_STATUS_IN_PROGRESS def _summarize(items: list[QCEvaluation], phase_name: str) -> PhaseSummary: summary = PhaseSummary(phase=phase_name, total=len(items)) for item in items: # تمام سؤالات QC نیازمند تعیین تکلیف هستند (قبول، مردود یا مطرح نیست) summary.required_total += 1 if not item.satisfied: summary.required_unresolved += 1 ans = item.answer if ans == QCAnswer.pass_.value: summary.passed += 1 elif ans == QCAnswer.fail.value: summary.failed += 1 elif ans is None: summary.pending += 1 else: summary.na += 1 return summary async def load_definitions(db: AsyncSession, task_id: Any) -> list[TaskQCDefinition]: """Definitions with questions + their full result history eagerly loaded.""" try: u = uuid.UUID(str(task_id)) except (ValueError, AttributeError): return [] stmt = ( select(TaskQCDefinition) .options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results)) .where(TaskQCDefinition.task_id == u) .order_by(TaskQCDefinition.created_at) ) rows = (await db.execute(stmt)).scalars().all() return list(rows) def _latest(results: list[TaskQCResult]) -> TaskQCResult | None: if not results: return None return max(results, key=lambda r: (r.evaluated_at, r.id)) def build_state(definitions: list[TaskQCDefinition]) -> TaskQCState: evaluations: list[QCEvaluation] = [] for d in definitions: for q in d.questions: evaluations.append(QCEvaluation(question=q, latest=_latest(q.results))) return TaskQCState(items=evaluations) async def task_qc_state(db: AsyncSession, task_id: Any) -> TaskQCState: return build_state(await load_definitions(db, task_id)) def question_dict(evaluation: QCEvaluation) -> dict: question = evaluation.question latest = evaluation.latest return { "id": str(question.id), "definition_id": str(question.definition_id), "text": question.text, "phase": question.phase.value, "sort_order": question.sort_order, "required": question.required, "result": ( { "answer": latest.answer.value, "comment": latest.comment, "photo_url": f"/api/v1/files/{latest.photo_path}" if latest.photo_path else None, "photo_id": latest.photo_path, "evaluated_at": latest.evaluated_at.isoformat() if latest.evaluated_at else None, "evaluated_by": latest.evaluated_by, } if latest else None ), "history_count": len(question.results), } def definition_dict(definition: TaskQCDefinition, state: TaskQCState) -> dict: mine = [i for i in state.items if i.question.definition_id == definition.id] summary = _summarize(mine, "definition") return { "id": str(definition.id), "task_id": str(definition.task_id), "name": definition.name, "source_template_id": str(definition.source_template_id) if definition.source_template_id else None, "created_at": definition.created_at.isoformat() if definition.created_at else None, "questions": [question_dict(i) for i in mine], "summary": summary.as_dict(), } def state_dict(state: TaskQCState, progress: int, requested_percentage: int | None = None) -> dict: return { "pre": state.pre.as_dict(), "exec": state.exec_.as_dict(), "all": state.all_.as_dict(), "status": state.status(progress), "can_start": state.can_start, "can_complete": state.can_complete, "block_reasons": state.block_reasons(requested_percentage), } async def recompute_task_qc_status(db: AsyncSession, task_id: Any) -> str | None: """Store the derived QC status on the task (dashboard/analytics read it).""" db.expire_all() state = await task_qc_state(db, task_id) try: native_id = uuid.UUID(str(task_id)) except (ValueError, AttributeError): return None task = await db.scalar(select(Task).where(Task.id == native_id)) if task is None: return None task.qc_status = state.status(task.progress or 0) return task.qc_status