The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
277 lines
9.3 KiB
Python
277 lines
9.3 KiB
Python
"""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
|