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
835 lines
30 KiB
Python
835 lines
30 KiB
Python
"""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}}
|