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:
@@ -0,0 +1,834 @@
|
||||
"""Tasks: Native PM Construction Platform Tasks & Activities.
|
||||
|
||||
Supports:
|
||||
- Hierarchical tasks (Project -> Phase -> Activity -> Subtask) via self-referencing `parent_id`.
|
||||
- Dates, duration, status, priorities, progress.
|
||||
- Full dependency relations & scheduling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import Project, ProjectMember, Task, TaskPriority, TaskProgressLog, TaskRelation, TaskStatus
|
||||
from app.qc_service import build_state, definition_dict, load_definitions, state_dict
|
||||
from app.routers.qc import _defect_dict, defects_for_task
|
||||
from app.serializers import _iso, native_task_dict, task_relation_dict
|
||||
from app.utils.date_service import date_service
|
||||
from app.scheduling_service import sync_project_lifecycle_dates
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
view_tasks = require_permission(Perm.tasks_view)
|
||||
manage_tasks = require_permission(Perm.tasks_create)
|
||||
update_tasks = require_permission(Perm.tasks_update)
|
||||
progress_tasks = require_permission(Perm.tasks_progress)
|
||||
|
||||
|
||||
class HumanResourceItem(BaseModel):
|
||||
role: str = Field(..., min_length=1)
|
||||
count: int = Field(1, ge=1)
|
||||
|
||||
|
||||
class MaterialItem(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
unit: str = Field("", max_length=64)
|
||||
specifications: list[str] = Field(default_factory=list)
|
||||
count: float = Field(1.0, ge=0)
|
||||
|
||||
|
||||
class TaskCreateSchema(BaseModel):
|
||||
project_id: str
|
||||
parent_id: str | None = None
|
||||
title: str = Field(..., min_length=1, max_length=512)
|
||||
description: str = ""
|
||||
status: str = TaskStatus.TODO.value
|
||||
priority: str = TaskPriority.MEDIUM.value
|
||||
assignee_id: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
duration: int | None = None
|
||||
human_resources: list[HumanResourceItem] = Field(default_factory=list)
|
||||
materials: list[MaterialItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskUpdateSchema(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
priority: str | None = None
|
||||
assignee: str | None = None
|
||||
assignee_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
duration: int | None = None
|
||||
duration_days: int | None = None
|
||||
human_resources: list[HumanResourceItem] | None = None
|
||||
materials: list[MaterialItem] | None = None
|
||||
|
||||
|
||||
class HumanResourceConsumption(BaseModel):
|
||||
role: str = ""
|
||||
planned_count: int | None = None
|
||||
actual_count: int = 1
|
||||
note: str = ""
|
||||
|
||||
|
||||
class MaterialConsumption(BaseModel):
|
||||
name: str = ""
|
||||
unit: str = ""
|
||||
amount: float = 0
|
||||
allocated_amount: float | None = None
|
||||
previous_consumed: float | None = None
|
||||
total_consumed: float | None = None
|
||||
remaining_amount: float | None = None
|
||||
is_over_allocated: bool = False
|
||||
over_allocated_amount: float = 0.0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class TaskProgressSchema(BaseModel):
|
||||
percentage: int = Field(..., ge=0, le=100)
|
||||
human_resources_used: list[HumanResourceConsumption] = Field(default_factory=list)
|
||||
materials_used: list[MaterialConsumption] = Field(default_factory=list)
|
||||
description: str = ""
|
||||
|
||||
|
||||
class TaskDatesSchema(BaseModel):
|
||||
start_date: str | None = None
|
||||
due_date: str | None = None
|
||||
|
||||
|
||||
async def _resolve_task(db: AsyncSession, ctx: AuthContext, task_id_or_op: str | int | uuid.UUID) -> Task:
|
||||
"""Find a native task by UUID, enforcing the caller's scope."""
|
||||
query = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project),
|
||||
selectinload(Task.assignee),
|
||||
selectinload(Task.children),
|
||||
selectinload(Task.parent),
|
||||
selectinload(Task.progress_logs),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
val_uuid = uuid.UUID(str(task_id_or_op))
|
||||
stmt = query.where(Task.id == val_uuid)
|
||||
except ValueError:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
|
||||
task = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not task:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
ctx.ensure_native_project(task.project_id)
|
||||
return task
|
||||
|
||||
|
||||
async def _validate_task_dates_and_hierarchy(
|
||||
db: AsyncSession,
|
||||
task_id: uuid.UUID | None,
|
||||
planned_start: date | None,
|
||||
planned_end: date | None,
|
||||
duration: int | None,
|
||||
parent_id: uuid.UUID | None,
|
||||
actual_start: date | None = None,
|
||||
actual_end: date | None = None,
|
||||
) -> None:
|
||||
# 0. اجباری بودن تاریخ شروع و تاریخ پایان
|
||||
if not planned_start:
|
||||
raise ValidationError("تاریخ شروع وظیفه الزامی است", field="planned_start")
|
||||
if not planned_end:
|
||||
raise ValidationError("تاریخ پایان وظیفه الزامی است", field="planned_end")
|
||||
|
||||
# 1. Start date must not be later than end date
|
||||
if planned_start and planned_end and planned_start > planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع یک وظیفه ({date_service.to_jalali(planned_start)}) نمیتواند بعد از تاریخ اتمام آن ({date_service.to_jalali(planned_end)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
|
||||
if actual_start and actual_end and actual_start > actual_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع واقعی ({date_service.to_jalali(actual_start)}) نمیتواند بعد از تاریخ پایان واقعی ({date_service.to_jalali(actual_end)}) باشد",
|
||||
field="actual_start",
|
||||
)
|
||||
|
||||
# 2. Parent vs Child constraint: Parent must envelop child
|
||||
if parent_id:
|
||||
parent = await db.scalar(select(Task).where(Task.id == parent_id))
|
||||
if parent:
|
||||
if parent.planned_start and planned_start and planned_start < parent.planned_start:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع وظیفه فرزند ({date_service.to_jalali(planned_start)}) نمیتواند قبل از تاریخ شروع وظیفه والد ({date_service.to_jalali(parent.planned_start)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
if parent.planned_end and planned_end and planned_end > parent.planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ پایان وظیفه فرزند ({date_service.to_jalali(planned_end)}) نمیتواند بعد از تاریخ پایان وظیفه والد ({date_service.to_jalali(parent.planned_end)}) باشد",
|
||||
field="planned_end",
|
||||
)
|
||||
if parent.duration and duration and duration > parent.duration:
|
||||
raise ValidationError(
|
||||
f"مدت زمان وظیفه والد ({parent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند ({duration} روز) باشد",
|
||||
field="duration",
|
||||
)
|
||||
|
||||
# 3. If this task is a parent, its time span/duration must be >= all its children
|
||||
if task_id:
|
||||
children_stmt = select(Task).where(Task.parent_id == task_id)
|
||||
children = (await db.execute(children_stmt)).scalars().all()
|
||||
for c in children:
|
||||
if planned_start and c.planned_start and planned_start > c.planned_start:
|
||||
raise ValidationError(
|
||||
f"تاریخ شروع وظیفه والد ({date_service.to_jalali(planned_start)}) نمیتواند بعد از تاریخ شروع وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_start)}) باشد",
|
||||
field="planned_start",
|
||||
)
|
||||
if planned_end and c.planned_end and planned_end < c.planned_end:
|
||||
raise ValidationError(
|
||||
f"تاریخ پایان وظیفه والد ({date_service.to_jalali(planned_end)}) نمیتواند قبل از تاریخ پایان وظیفه فرزند «{c.title}» ({date_service.to_jalali(c.planned_end)}) باشد",
|
||||
field="planned_end",
|
||||
)
|
||||
if duration and c.duration and duration < c.duration:
|
||||
raise ValidationError(
|
||||
f"مدت زمان وظیفه والد ({duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «{c.title}» ({c.duration} روز) باشد",
|
||||
field="duration",
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(
|
||||
project_id: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
search: str | None = None,
|
||||
status: str | None = None,
|
||||
qc_status: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(300, ge=1, le=1000),
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List native tasks with flexible filtering."""
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.project),
|
||||
selectinload(Task.assignee),
|
||||
selectinload(Task.children),
|
||||
)
|
||||
.order_by(Task.created_at.asc())
|
||||
)
|
||||
|
||||
# Filter by project
|
||||
if project_id is not None:
|
||||
try:
|
||||
p_uuid = uuid.UUID(project_id)
|
||||
stmt = stmt.where(Task.project_id == p_uuid)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Project scoping for non-managers — native projects are keyed by UUID
|
||||
stmt = ctx.scope_native(stmt, Task.project_id)
|
||||
|
||||
if parent_id is not None:
|
||||
if parent_id.lower() in ("null", "root", "none"):
|
||||
stmt = stmt.where(Task.parent_id.is_(None))
|
||||
else:
|
||||
stmt = stmt.where(Task.parent_id == uuid.UUID(parent_id))
|
||||
|
||||
if search:
|
||||
stmt = stmt.where(Task.title.ilike(f"%{search.strip()}%"))
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Task.status == status.upper())
|
||||
|
||||
if qc_status:
|
||||
stmt = stmt.where(Task.qc_status == qc_status)
|
||||
|
||||
total = (await db.scalar(select(func.count()).select_from(stmt.subquery()))) or 0
|
||||
tasks = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
|
||||
|
||||
# Count children in memory from loaded tasks to prevent lazy-load I/O
|
||||
child_counts: dict[uuid.UUID, int] = {}
|
||||
for t in tasks:
|
||||
if t.parent_id:
|
||||
child_counts[t.parent_id] = child_counts.get(t.parent_id, 0) + 1
|
||||
|
||||
results = []
|
||||
for t in tasks:
|
||||
p_name = t.project.name if t.project else None
|
||||
c_count = child_counts.get(t.id, 0)
|
||||
d = native_task_dict(t, project_name=p_name, children_count=c_count)
|
||||
d["subject"] = t.title
|
||||
d["percentage_done"] = t.progress
|
||||
d["start_date"] = _iso(t.planned_start)
|
||||
d["due_date"] = _iso(t.planned_end)
|
||||
d["is_closed"] = t.status == TaskStatus.COMPLETED.value
|
||||
results.append(d)
|
||||
|
||||
return {"data": results, "meta": {"total": total}}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_task(
|
||||
payload: TaskCreateSchema,
|
||||
ctx: AuthContext = Depends(manage_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new native task / subtask."""
|
||||
# Resolve project
|
||||
try:
|
||||
p_uuid = uuid.UUID(payload.project_id)
|
||||
project = await db.scalar(select(Project).where(Project.id == p_uuid))
|
||||
except ValueError:
|
||||
project = None
|
||||
|
||||
if not project:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
|
||||
# Resolve parent task if specified
|
||||
parent_uuid = None
|
||||
if payload.parent_id:
|
||||
parent_task = await _resolve_task(db, ctx, payload.parent_id)
|
||||
parent_uuid = parent_task.id
|
||||
|
||||
p_start = date_service.parse(payload.planned_start)
|
||||
p_end = date_service.parse(payload.planned_end)
|
||||
duration = payload.duration or 0
|
||||
|
||||
if p_start and p_end:
|
||||
duration = date_service.calculate_duration(p_start, p_end)
|
||||
elif p_start and duration:
|
||||
p_end = date_service.compute_end_date(p_start, duration)
|
||||
elif p_end and duration:
|
||||
p_start = p_end - timedelta(days=duration)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=None,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=duration,
|
||||
parent_id=parent_uuid,
|
||||
)
|
||||
|
||||
assignee_uuid = None
|
||||
assignee_name = None
|
||||
if payload.assignee_id:
|
||||
try:
|
||||
assignee_uuid = uuid.UUID(payload.assignee_id)
|
||||
except ValueError:
|
||||
assignee_name = payload.assignee_id
|
||||
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
parent_id=parent_uuid,
|
||||
title=payload.title.strip(),
|
||||
description=payload.description.strip(),
|
||||
status=payload.status.upper(),
|
||||
priority=payload.priority.lower(),
|
||||
assignee_id=assignee_uuid,
|
||||
assignee_name=assignee_name,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=duration,
|
||||
progress=0,
|
||||
human_resources=[hr.model_dump() for hr in payload.human_resources],
|
||||
materials=[m.model_dump() for m in payload.materials],
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await sync_project_lifecycle_dates(db, project.id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
d = native_task_dict(task, project_name=project.name)
|
||||
d["subject"] = task.title
|
||||
d["percentage_done"] = task.progress
|
||||
return {"data": d}
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_task_detail(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get full task details, including hierarchy, QC state, and defects."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
# QC definitions and execution state
|
||||
definitions = await load_definitions(db, task.id)
|
||||
state = build_state(definitions)
|
||||
|
||||
# Defects
|
||||
defects = await defects_for_task(db, task.id)
|
||||
|
||||
children_data = []
|
||||
for c in task.children:
|
||||
c_dict = native_task_dict(c, project_name=task.project.name if task.project else None)
|
||||
c_dict["subject"] = c.title
|
||||
c_dict["percentage_done"] = c.progress
|
||||
children_data.append(c_dict)
|
||||
|
||||
data = native_task_dict(
|
||||
task,
|
||||
project_name=task.project.name if task.project else None,
|
||||
children_count=len(task.children),
|
||||
)
|
||||
data["subject"] = task.title
|
||||
data["percentage_done"] = task.progress
|
||||
data["start_date"] = _iso(task.planned_start)
|
||||
data["due_date"] = _iso(task.planned_end)
|
||||
data["start_date_jalali"] = date_service.to_jalali(task.planned_start)
|
||||
data["due_date_jalali"] = date_service.to_jalali(task.planned_end)
|
||||
data["is_closed"] = task.status == TaskStatus.COMPLETED.value
|
||||
|
||||
# Load task relations (predecessors and successors)
|
||||
rel_stmt = (
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(
|
||||
or_(
|
||||
TaskRelation.from_task_id == task.id,
|
||||
TaskRelation.to_task_id == task.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task_rels = (await db.execute(rel_stmt)).scalars().all()
|
||||
incoming = []
|
||||
outgoing = []
|
||||
for r in task_rels:
|
||||
rd = task_relation_dict(r)
|
||||
if r.to_task_id == task.id:
|
||||
# incoming: predecessor (from_task) -> current task
|
||||
incoming.append({
|
||||
**rd,
|
||||
"id": str(r.id),
|
||||
"op_wp_id": str(r.from_task_id),
|
||||
"task_id": str(r.from_task_id),
|
||||
"subject": r.from_task.title if r.from_task else "",
|
||||
"relation_type": r.relation_type,
|
||||
"lag_days": r.lag_days,
|
||||
})
|
||||
if r.from_task_id == task.id:
|
||||
# outgoing: current task -> successor (to_task)
|
||||
outgoing.append({
|
||||
**rd,
|
||||
"id": str(r.id),
|
||||
"op_wp_id": str(r.to_task_id),
|
||||
"task_id": str(r.to_task_id),
|
||||
"subject": r.to_task.title if r.to_task else "",
|
||||
"relation_type": r.relation_type,
|
||||
"lag_days": r.lag_days,
|
||||
})
|
||||
data["relations"] = {"blocks": outgoing, "blocked_by": incoming}
|
||||
data["all_relations"] = [task_relation_dict(r) for r in task_rels]
|
||||
data["children"] = children_data
|
||||
data["parent"] = (
|
||||
{"id": str(task.parent.id), "title": task.parent.title} if task.parent else None
|
||||
)
|
||||
data["qc"] = state_dict(state, progress=task.progress)
|
||||
data["qc_definitions"] = [definition_dict(d, state) for d in definitions]
|
||||
data["defects"] = [_defect_dict(d) for d in defects]
|
||||
data["open_defects"] = sum(1 for d in defects if d.status != "resolved")
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.patch("/{id}")
|
||||
async def update_task(
|
||||
id: str,
|
||||
payload: TaskUpdateSchema,
|
||||
ctx: AuthContext = Depends(update_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task title, description, status, priority, or dates."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
if payload.title is not None:
|
||||
task.title = payload.title.strip()
|
||||
if payload.description is not None:
|
||||
task.description = payload.description.strip()
|
||||
if payload.status is not None:
|
||||
task.status = payload.status.upper()
|
||||
if payload.priority is not None:
|
||||
task.priority = payload.priority.lower()
|
||||
if payload.assignee is not None:
|
||||
task.assignee_name = payload.assignee.strip() or None
|
||||
if payload.assignee_id is not None:
|
||||
try:
|
||||
task.assignee_id = uuid.UUID(payload.assignee_id)
|
||||
except ValueError:
|
||||
task.assignee_name = payload.assignee_id
|
||||
if payload.parent_id is not None:
|
||||
clean_p = payload.parent_id.strip().lower()
|
||||
if not clean_p or clean_p in ("none", "null", "root"):
|
||||
task.parent_id = None
|
||||
else:
|
||||
parent = await _resolve_task(db, ctx, payload.parent_id)
|
||||
if parent.id == task.id:
|
||||
raise ValidationError("تسک نمیتواند والد خودش باشد")
|
||||
# Prevent cycle: ensure task is not in ancestors of parent
|
||||
check = parent
|
||||
while check:
|
||||
if check.id == task.id:
|
||||
raise ValidationError("نمیتوان یک زیرتسک را به عنوان والد انتخاب کرد (وابستگی چرخهای)")
|
||||
if check.parent_id:
|
||||
check = await db.scalar(select(Task).where(Task.id == check.parent_id))
|
||||
else:
|
||||
break
|
||||
task.parent_id = parent.id
|
||||
|
||||
p_start = task.planned_start
|
||||
p_end = task.planned_end
|
||||
a_start = task.actual_start
|
||||
a_end = task.actual_end
|
||||
dur = task.duration
|
||||
|
||||
if payload.planned_start is not None:
|
||||
p_start = date_service.parse(payload.planned_start)
|
||||
if payload.planned_end is not None:
|
||||
p_end = date_service.parse(payload.planned_end)
|
||||
|
||||
dur_input = payload.duration if payload.duration is not None else payload.duration_days
|
||||
if dur_input is not None:
|
||||
dur = max(0, dur_input)
|
||||
if p_start:
|
||||
p_end = date_service.compute_end_date(p_start, dur)
|
||||
elif p_start and p_end:
|
||||
dur = date_service.calculate_duration(p_start, p_end)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=task.id,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=dur,
|
||||
parent_id=task.parent_id,
|
||||
actual_start=a_start,
|
||||
actual_end=a_end,
|
||||
)
|
||||
|
||||
task.planned_start = p_start
|
||||
task.planned_end = p_end
|
||||
task.duration = dur
|
||||
|
||||
if payload.human_resources is not None:
|
||||
task.human_resources = [hr.model_dump() for hr in payload.human_resources]
|
||||
if payload.materials is not None:
|
||||
task.materials = [m.model_dump() for m in payload.materials]
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
task = await _resolve_task(db, ctx, task.id)
|
||||
return {"data": native_task_dict(task, project_name=task.project.name if task.project else None)}
|
||||
|
||||
|
||||
@router.patch("/{id}/progress")
|
||||
async def update_task_progress(
|
||||
id: str,
|
||||
payload: TaskProgressSchema,
|
||||
ctx: AuthContext = Depends(progress_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task progress (0-100%) with strict server-side QC validation rules."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
percentage = payload.percentage
|
||||
|
||||
# 1. Evaluate QC business rules
|
||||
definitions = await load_definitions(db, task.id)
|
||||
state = build_state(definitions)
|
||||
|
||||
# Server-side Invariant Rule 1: pre-QC must be satisfied if progress > 0
|
||||
if percentage > 0 and not state.can_start:
|
||||
unresolved = state.pre.required_unresolved
|
||||
msg = (
|
||||
f"بازرسی پیش از اجرا تأیید نشده است ({unresolved} سؤال باقیمانده)"
|
||||
if unresolved > 0
|
||||
else "بازرسی پیش از اجرا مردود شده است و نیاز به رفع و بازرسی مجدد دارد"
|
||||
)
|
||||
raise ValidationError(msg, field="percentage")
|
||||
|
||||
# Server-side Invariant Rule 2: all required QC must be satisfied if progress >= 100
|
||||
if percentage >= 100 and not state.can_complete:
|
||||
unresolved = state.all_.required_unresolved
|
||||
failed = state.all_.failed
|
||||
raise ValidationError(
|
||||
f"تا زمانی که همه بازرسیهای الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست ({unresolved} سؤال باقیمانده، {failed} مورد مردود)",
|
||||
field="percentage",
|
||||
)
|
||||
|
||||
# 2. Update task progress and lifecycle dates
|
||||
progress_before = task.progress or 0
|
||||
task.progress = percentage
|
||||
if percentage > 0:
|
||||
if not task.actual_start:
|
||||
task.actual_start = date_service.today()
|
||||
if task.status == TaskStatus.TODO.value:
|
||||
task.status = TaskStatus.IN_PROGRESS.value
|
||||
else:
|
||||
# percentage == 0: reset actual dates
|
||||
task.actual_start = None
|
||||
task.actual_end = None
|
||||
task.status = TaskStatus.TODO.value
|
||||
|
||||
if percentage >= 100:
|
||||
task.actual_end = date_service.today()
|
||||
task.status = TaskStatus.COMPLETED.value
|
||||
elif 0 < percentage < 100:
|
||||
task.actual_end = None
|
||||
if task.status == TaskStatus.COMPLETED.value:
|
||||
task.status = TaskStatus.IN_PROGRESS.value
|
||||
|
||||
# 3. Log consumption of human resources and materials
|
||||
hr_list = [h.model_dump() for h in payload.human_resources_used if h.role.strip()]
|
||||
|
||||
# Build allocation lookup from task.materials
|
||||
alloc_map = {}
|
||||
for item in (task.materials or []):
|
||||
if isinstance(item, dict):
|
||||
m_name = (item.get("name") or "").strip().lower()
|
||||
if m_name:
|
||||
alloc_map[m_name] = item
|
||||
|
||||
# Calculate previously consumed amounts from prior logs
|
||||
past_consumed_map: dict[str, float] = {}
|
||||
for plog in (task.progress_logs or []):
|
||||
for pmat in (plog.materials_used or []):
|
||||
if isinstance(pmat, dict):
|
||||
p_name = (pmat.get("name") or "").strip().lower()
|
||||
if p_name:
|
||||
past_consumed_map[p_name] = past_consumed_map.get(p_name, 0.0) + float(pmat.get("amount") or 0.0)
|
||||
|
||||
mat_list = []
|
||||
for m in payload.materials_used:
|
||||
name = m.name.strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.lower()
|
||||
matched = alloc_map.get(key)
|
||||
|
||||
# Base allocation: explicitly passed or from task.materials
|
||||
if m.allocated_amount is not None:
|
||||
alloc_amt = float(m.allocated_amount)
|
||||
elif matched:
|
||||
alloc_amt = float(matched.get("count") or 0.0)
|
||||
else:
|
||||
alloc_amt = 0.0
|
||||
|
||||
unit = m.unit.strip() or (matched.get("unit", "") if matched else "")
|
||||
amount = float(m.amount or 0.0)
|
||||
prev_consumed = past_consumed_map.get(key, 0.0)
|
||||
tot_consumed = prev_consumed + amount
|
||||
rem_amount = max(0.0, alloc_amt - tot_consumed) if alloc_amt > 0 else 0.0
|
||||
is_over = (tot_consumed > alloc_amt) if alloc_amt > 0 else (tot_consumed > 0)
|
||||
over_amt = max(0.0, tot_consumed - alloc_amt) if alloc_amt > 0 else tot_consumed
|
||||
|
||||
mat_list.append({
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"amount": amount,
|
||||
"allocated_amount": alloc_amt,
|
||||
"previous_consumed": prev_consumed,
|
||||
"total_consumed": tot_consumed,
|
||||
"remaining_amount": rem_amount,
|
||||
"is_over_allocated": is_over,
|
||||
"over_allocated_amount": over_amt,
|
||||
"note": m.note.strip() if m.note else "",
|
||||
})
|
||||
|
||||
log = TaskProgressLog(
|
||||
task_id=task.id,
|
||||
progress_before=progress_before,
|
||||
progress_after=percentage,
|
||||
human_resources_used=hr_list,
|
||||
materials_used=mat_list,
|
||||
description=payload.description.strip(),
|
||||
logged_by=ctx.user.display_name or ctx.user.username,
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"id": str(task.id),
|
||||
"progress": task.progress,
|
||||
"status": task.status,
|
||||
"qc_status": task.qc_status,
|
||||
"actual_start": _iso(task.actual_start),
|
||||
"actual_end": _iso(task.actual_end),
|
||||
"progress_log": {
|
||||
"id": str(log.id),
|
||||
"progress_before": log.progress_before,
|
||||
"progress_after": log.progress_after,
|
||||
"human_resources_used": log.human_resources_used,
|
||||
"materials_used": log.materials_used,
|
||||
"description": log.description,
|
||||
"logged_by": log.logged_by,
|
||||
"created_at": _iso(log.created_at),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/material-audit")
|
||||
async def get_task_material_audit(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""استعلام مقایسهای مصالح تخصیصیافته در برابر کل مصارف واقعی و گزارش اضافه مصرف."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
alloc_map = {}
|
||||
for m in (task.materials or []):
|
||||
if isinstance(m, dict):
|
||||
name = (m.get("name") or "").strip()
|
||||
if name:
|
||||
alloc_map[name.lower()] = {
|
||||
"name": name,
|
||||
"unit": m.get("unit") or "",
|
||||
"allocated_amount": float(m.get("count") or 0.0),
|
||||
"specifications": m.get("specifications") or [],
|
||||
}
|
||||
|
||||
consumption_map: dict[str, dict] = {}
|
||||
for log in (task.progress_logs or []):
|
||||
for mu in (log.materials_used or []):
|
||||
if isinstance(mu, dict):
|
||||
mname = (mu.get("name") or "").strip()
|
||||
if not mname:
|
||||
continue
|
||||
key = mname.lower()
|
||||
m_amt = float(mu.get("amount") or 0.0)
|
||||
m_unit = mu.get("unit") or ""
|
||||
if key not in consumption_map:
|
||||
consumption_map[key] = {
|
||||
"name": mname,
|
||||
"unit": m_unit,
|
||||
"total_consumed": 0.0,
|
||||
}
|
||||
consumption_map[key]["total_consumed"] += m_amt
|
||||
|
||||
all_keys = set(alloc_map.keys()) | set(consumption_map.keys())
|
||||
audit_items = []
|
||||
total_over_allocated_count = 0
|
||||
|
||||
for key in sorted(all_keys):
|
||||
alloc = alloc_map.get(key)
|
||||
consumed = consumption_map.get(key)
|
||||
|
||||
name = alloc["name"] if alloc else consumed["name"]
|
||||
unit = alloc["unit"] if alloc else consumed["unit"]
|
||||
alloc_amt = alloc["allocated_amount"] if alloc else 0.0
|
||||
tot_cons = consumed["total_consumed"] if consumed else 0.0
|
||||
rem_amt = max(0.0, alloc_amt - tot_cons) if alloc_amt > 0 else 0.0
|
||||
is_over = (tot_cons > alloc_amt) if alloc_amt > 0 else (tot_cons > 0)
|
||||
over_amt = max(0.0, tot_cons - alloc_amt) if alloc_amt > 0 else tot_cons
|
||||
|
||||
if is_over:
|
||||
total_over_allocated_count += 1
|
||||
|
||||
audit_items.append({
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"allocated_amount": alloc_amt,
|
||||
"total_consumed": tot_cons,
|
||||
"remaining_amount": rem_amt,
|
||||
"is_over_allocated": is_over,
|
||||
"over_allocated_amount": over_amt,
|
||||
"specifications": alloc["specifications"] if alloc else [],
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"task_id": str(task.id),
|
||||
"task_title": task.title,
|
||||
"has_over_allocation": total_over_allocated_count > 0,
|
||||
"over_allocated_items_count": total_over_allocated_count,
|
||||
"items": audit_items,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{id}/dates")
|
||||
async def update_task_dates(
|
||||
id: str,
|
||||
payload: TaskDatesSchema,
|
||||
ctx: AuthContext = Depends(update_tasks),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update task dates (dual Jalali/ISO compatible)."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
|
||||
p_start = task.planned_start
|
||||
p_end = task.planned_end
|
||||
|
||||
if payload.start_date is not None:
|
||||
p_start = date_service.parse(payload.start_date)
|
||||
if payload.due_date is not None:
|
||||
p_end = date_service.parse(payload.due_date)
|
||||
|
||||
dur = task.duration
|
||||
if p_start and p_end:
|
||||
dur = date_service.calculate_duration(p_start, p_end)
|
||||
|
||||
await _validate_task_dates_and_hierarchy(
|
||||
db=db,
|
||||
task_id=task.id,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
duration=dur,
|
||||
parent_id=task.parent_id,
|
||||
actual_start=task.actual_start,
|
||||
actual_end=task.actual_end,
|
||||
)
|
||||
|
||||
task.planned_start = p_start
|
||||
task.planned_end = p_end
|
||||
task.duration = dur
|
||||
|
||||
await sync_project_lifecycle_dates(db, task.project_id)
|
||||
await db.commit()
|
||||
task = await _resolve_task(db, ctx, task.id)
|
||||
return {
|
||||
"data": {
|
||||
"id": str(task.id),
|
||||
"start_date": _iso(task.planned_start),
|
||||
"due_date": _iso(task.planned_end),
|
||||
"start_date_jalali": date_service.to_jalali(task.planned_start),
|
||||
"due_date_jalali": date_service.to_jalali(task.planned_end),
|
||||
"duration": task.duration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_task(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task and its children."""
|
||||
task = await _resolve_task(db, ctx, id)
|
||||
project_id = task.project_id
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
await sync_project_lifecycle_dates(db, project_id)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True, "id": id}}
|
||||
Reference in New Issue
Block a user