chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)

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
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
"""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