Files
ERP/pm/backend/app/routers/tasks.py
T
MMD 2747f9d4bb 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
2026-10-07 15:14:20 +03:30

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}}