Files
ERP/backend/app/routers/qc.py
T
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30

730 lines
24 KiB
Python

"""QC module.
Two clearly separated halves:
**تعریف (Definition)** — what must eventually be checked
* `QCTemplate` + `QCQuestion` : global, reusable, editable
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
**اجرا (Execution)** — the actual evaluation during the project
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
* `Defect` : problems raised on a task, with corrective action
Applying a template to a task deep-copies its questions, so customizing a task
never mutates the global template.
Access split (app/authz.py):
* `qc.define` — templates + the per-task QC structure: project manager only
* `qc.execute` — recording answers and defects, on assigned projects
* `qc.view` — reading status/readiness, on assigned projects
"""
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.authz import (
AuthContext,
Perm,
require_permission,
scoped_task,
)
from app.db import get_db
from app.errors import NotFoundError, ValidationError
from app.models import (
Defect,
QCAnswer,
QCQuestion,
QCQuestionPhase,
QCTemplate,
Task,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
)
from app.qc_service import (
PRE,
build_state,
definition_dict,
load_definitions,
question_dict,
recompute_task_qc_status,
state_dict,
task_qc_state,
)
from app.serializers import _iso
router = APIRouter(prefix="/qc", tags=["qc"])
define_qc = require_permission(Perm.qc_define)
execute_qc = require_permission(Perm.qc_execute)
view_qc = require_permission(Perm.qc_view)
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
def _phase(value: str) -> QCQuestionPhase:
try:
return QCQuestionPhase(value)
except ValueError as exc:
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
def _answer(value: str) -> QCAnswer:
try:
return QCAnswer(value)
except ValueError as exc:
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
@dataclass
class QCTaskRef:
"""A task as the QC module sees it."""
id: uuid.UUID
subject: str
progress: int
status: str
@property
def key(self):
return self.id
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
native = await scoped_task(db, ctx, task_ref)
return QCTaskRef(
id=native.id,
subject=native.title or "",
progress=native.progress or 0,
status=native.status or "",
)
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
native = await db.get(Task, definition.task_id)
if native is not None:
ctx.ensure_project(native.project_id)
return
raise NotFoundError("تعریف QC یافت نشد")
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
row = await db.scalar(
select(TaskQCDefinition)
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
.where(TaskQCDefinition.id == definition_id)
)
if row is None:
raise NotFoundError("تعریف QC یافت نشد")
return row
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
definition = await _definition(db, definition_id)
await _ensure_definition_scope(db, ctx, definition)
return definition
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
row = await db.scalar(
select(TaskQCQuestion)
.options(selectinload(TaskQCQuestion.results))
.where(TaskQCQuestion.id == question_id)
)
if row is None:
raise NotFoundError("سؤال QC یافت نشد")
return row
async def _scoped_definition_question(
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
question = await _question(db, question_id)
definition = await db.get(TaskQCDefinition, question.definition_id)
if definition is None:
raise NotFoundError("تعریف QC یافت نشد")
await _ensure_definition_scope(db, ctx, definition)
return question, definition
# =============================================================================
# Global QC templates
# =============================================================================
def _template_dict(template: QCTemplate) -> dict:
return {
"id": str(template.id),
"name": template.name,
"activity_type": template.activity_type,
"description": template.description,
"questions": [
{
"id": str(q.id),
"phase": q.phase.value,
"text": q.text,
"sort_order": q.sort_order,
"required": q.required,
}
for q in template.questions
],
}
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
row = await db.scalar(
select(QCTemplate)
.options(selectinload(QCTemplate.questions))
.where(QCTemplate.id == template_id)
)
if row is None:
raise NotFoundError("قالب QC یافت نشد")
return row
@router.get("/templates")
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
if activity_type:
stmt = stmt.where(QCTemplate.activity_type == activity_type)
templates = (await db.execute(stmt)).scalars().all()
return {"data": [_template_dict(t) for t in templates]}
@router.get("/templates/{template_id}")
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
return {"data": _template_dict(await _template(db, template_id))}
class QuestionIn(BaseModel):
phase: str
text: str
required: bool = True
sort_order: int = 0
class TemplateIn(BaseModel):
name: str
activity_type: str = ""
description: str = ""
questions: list[QuestionIn] = []
@field_validator("name")
@classmethod
def _name_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("نام قالب الزامی است")
return v.strip()
@router.post("/templates")
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = QCTemplate(
name=payload.name,
activity_type=payload.activity_type,
description=payload.description,
)
db.add(template)
await db.flush()
for index, q in enumerate(payload.questions):
db.add(
QCQuestion(
template_id=template.id,
phase=_phase(q.phase),
text=q.text.strip(),
sort_order=q.sort_order if q.sort_order else index,
required=q.required,
)
)
await db.commit()
return {"data": _template_dict(await _template(db, template.id))}
class TemplatePatch(BaseModel):
name: str | None = None
activity_type: str | None = None
description: str | None = None
@router.patch("/templates/{template_id}")
async def patch_template(
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
if payload.name is not None:
if not payload.name.strip():
raise ValidationError("نام قالب نمی‌تواند خالی باشد", field="name")
template.name = payload.name.strip()
if payload.activity_type is not None:
template.activity_type = payload.activity_type
if payload.description is not None:
template.description = payload.description
await db.commit()
return {"data": _template_dict(template)}
@router.delete("/templates/{template_id}")
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
template = await _template(db, template_id)
await db.delete(template)
await db.commit()
return {"data": {"deleted": True}}
@router.post("/templates/{template_id}/questions")
async def add_template_question(
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
template = await _template(db, template_id)
question = QCQuestion(
template_id=template.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
await db.commit()
return {"data": _template_dict(await _template(db, template_id))}
class QuestionPatch(BaseModel):
text: str | None = None
phase: str | None = None
required: bool | None = None
sort_order: int | None = None
@router.patch("/questions/{question_id}")
async def patch_question(
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
if payload.text is not None:
if not payload.text.strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = payload.text.strip()
if payload.phase is not None:
question.phase = _phase(payload.phase)
if payload.required is not None:
question.required = payload.required
if payload.sort_order is not None:
question.sort_order = payload.sort_order
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/questions/{question_id}")
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
question = await db.get(QCQuestion, question_id)
if question is None:
raise NotFoundError("سؤال QC یافت نشد")
await db.delete(question)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Task QC Definitions
# =============================================================================
class DefinitionIn(BaseModel):
name: str = ""
template_id: str | None = None
@router.get("/tasks/{task_ref}")
async def task_qc(
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
):
"""Everything the QC screen needs: definitions + questions + results + readiness."""
task = await _qc_task(db, ctx, task_ref)
definitions = await load_definitions(db, task.key)
state = build_state(definitions)
return {
"data": {
"task": {
"id": str(task.id),
"subject": task.subject,
"percentage_done": task.progress,
"status": task.status,
},
"definitions": [definition_dict(d, state) for d in definitions],
"readiness": state_dict(state, progress=task.progress),
}
}
@router.post("/tasks/{task_ref}/definition")
async def create_definition(
task_ref: str,
payload: DefinitionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
task = await _qc_task(db, ctx, task_ref)
template: QCTemplate | None = None
if payload.template_id:
try:
template_uuid = uuid.UUID(payload.template_id)
except ValueError as exc:
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
template = await _template(db, template_uuid)
definition = TaskQCDefinition(
task_id=task.id,
name=payload.name.strip() or (template.name if template else "QC تسک"),
source_template_id=template.id if template else None,
)
db.add(definition)
await db.flush()
if template:
for index, question in enumerate(template.questions):
db.add(
TaskQCQuestion(
definition_id=definition.id,
phase=question.phase,
text=question.text,
sort_order=index,
required=question.required,
)
)
await db.commit()
await recompute_task_qc_status(db, task.key)
await db.commit()
return {"data": {"id": str(definition.id)}}
class DefinitionPatch(BaseModel):
name: str
@router.patch("/definitions/{definition_id}")
async def patch_definition(
definition_id: uuid.UUID,
payload: DefinitionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
if not payload.name.strip():
raise ValidationError("نام تعریف الزامی است", field="name")
definition.name = payload.name.strip()
await db.commit()
return {"data": {"id": str(definition.id), "name": definition.name}}
@router.delete("/definitions/{definition_id}")
async def delete_definition(
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
definition = await _scoped_definition(db, ctx, definition_id)
task_key = definition.task_id
await db.delete(definition)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
class TaskQuestionIn(BaseModel):
text: str
phase: str
required: bool = True
sort_order: int = 0
@router.post("/definitions/{definition_id}/questions")
async def add_task_question(
definition_id: uuid.UUID,
payload: TaskQuestionIn,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
definition = await _scoped_definition(db, ctx, definition_id)
question = TaskQCQuestion(
definition_id=definition.id,
phase=_phase(payload.phase),
text=payload.text.strip(),
sort_order=payload.sort_order,
required=payload.required,
)
db.add(question)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.patch("/task-questions/{question_id}")
async def patch_task_question(
question_id: uuid.UUID,
payload: QuestionPatch,
ctx: AuthContext = Depends(define_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
values = payload.model_dump(exclude_unset=True)
if "text" in values:
if not values["text"].strip():
raise ValidationError("متن سؤال نمی‌تواند خالی باشد", field="text")
question.text = values["text"].strip()
values.pop("text")
if "phase" in values:
question.phase = _phase(values.pop("phase"))
for key, value in values.items():
setattr(question, key, value)
definition.updated_at = datetime.now(timezone.utc)
await db.commit()
await recompute_task_qc_status(db, definition.task_id)
await db.commit()
return {"data": {"id": str(question.id)}}
@router.delete("/task-questions/{question_id}")
async def delete_task_question(
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
task_key = definition.task_id
await db.delete(question)
await db.commit()
await recompute_task_qc_status(db, task_key)
await db.commit()
return {"data": {"deleted": True}}
# =============================================================================
# Execution / evaluation
# =============================================================================
class ResultIn(BaseModel):
answer: str | None = None
comment: str = ""
photo_id: str | None = None
@router.put("/task-questions/{question_id}/result")
async def evaluate_question(
question_id: uuid.UUID,
payload: ResultIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمی‌گردد
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
user = ctx.user
answer = _answer(payload.answer)
existing_latest = await db.scalar(
select(TaskQCResult)
.where(TaskQCResult.question_id == question.id)
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
)
photo_path = payload.photo_id
if photo_path is None and existing_latest and existing_latest.answer == answer:
photo_path = existing_latest.photo_path
elif photo_path == "":
photo_path = None
db.add(
TaskQCResult(
question_id=question.id,
answer=answer,
comment=payload.comment.strip(),
photo_path=photo_path,
evaluated_by=user.display_name or user.username,
)
)
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": answer.value,
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
@router.delete("/task-questions/{question_id}/result")
async def delete_question_result(
question_id: uuid.UUID,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
question, definition = await _scoped_definition_question(db, ctx, question_id)
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
await db.commit()
status = await recompute_task_qc_status(db, definition.task_id)
await db.commit()
state = await task_qc_state(db, definition.task_id)
return {
"data": {
"question_id": str(question.id),
"answer": None,
"task_qc_status": status,
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
}
}
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
task = await db.scalar(select(Task).where(Task.id == task_id))
return (task.progress or 0) if task else 0
# =============================================================================
# Defects
# =============================================================================
def _defect_dict(defect: Defect) -> dict:
return {
"id": str(defect.id),
"task_id": str(defect.task_id),
"question_id": str(defect.question_id) if defect.question_id else None,
"title": defect.title,
"description": defect.description,
"severity": defect.severity,
"status": defect.status,
"corrective_action": defect.corrective_action,
"resolution_notes": defect.resolution_notes,
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
"created_at": _iso(defect.created_at),
"resolved_at": _iso(defect.resolved_at),
}
class DefectIn(BaseModel):
task_ref: str
question_id: str | None = None
title: str
description: str = ""
severity: str = "medium"
corrective_action: str = ""
photo_id: str | None = None
@field_validator("title")
@classmethod
def _title(cls, v: str) -> str:
if not v.strip():
raise ValueError("عنوان عیب الزامی است")
return v.strip()
@router.post("/defects")
async def create_defect(
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
):
task = await _qc_task(db, ctx, payload.task_ref)
question_id = None
if payload.question_id:
try:
question_id = uuid.UUID(payload.question_id)
except ValueError as exc:
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
defect = Defect(
task_id=task.id,
question_id=question_id,
title=payload.title,
description=payload.description,
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
corrective_action=payload.corrective_action,
photo_path=payload.photo_id,
)
db.add(defect)
await db.commit()
return {"data": _defect_dict(defect)}
class ResolveIn(BaseModel):
resolution_notes: str = ""
@router.post("/defects/{defect_id}/resolve")
async def resolve_defect(
defect_id: uuid.UUID,
payload: ResolveIn,
ctx: AuthContext = Depends(execute_qc),
db: AsyncSession = Depends(get_db),
):
defect = await db.get(Defect, defect_id)
if defect is None:
raise NotFoundError("عیب یافت نشد")
await scoped_task(db, ctx, defect.task_id)
if defect.status == "resolved":
raise ValidationError("این عیب قبلاً رفع شده است")
defect.status = "resolved"
defect.resolution_notes = payload.resolution_notes.strip()
defect.resolved_at = datetime.now(timezone.utc)
await db.commit()
return {"data": _defect_dict(defect)}
@router.get("/defects")
async def list_defects(
task_id: uuid.UUID | None = None,
status: str | None = None,
ctx: AuthContext = Depends(view_qc),
db: AsyncSession = Depends(get_db),
):
query = select(Defect).join(Task, Task.id == Defect.task_id)
if task_id:
query = query.where(Defect.task_id == task_id)
if status:
query = query.where(Defect.status == status)
query = ctx.scope(query, Task.project_id)
query = query.order_by(Defect.created_at.desc()).limit(150)
rows = (await db.execute(query)).scalars().all()
items = []
for d in rows:
task = await db.get(Task, d.task_id)
di = _defect_dict(d)
di["task_title"] = task.title if task else ""
items.append(di)
return {"data": items}
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
rows = (
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
).scalars().all()
return list(rows)