"""Topological Scheduling Engine for Construction Projects. Features: - Cycle Detection (DAG validation) - Forward Pass (Early Start / Early Finish propagation based on FS, SS, FF, SF + lag) - Backward Pass (Late Start / Late Finish & Float calculation) - Critical Path Method (CPM) identification - Summary task rollup (parent tasks envelop child start/end and progress) """ from collections import defaultdict, deque from datetime import date, timedelta import uuid from app.utils.date_service import date_service class CycleError(Exception): """Raised when a dependency would introduce a cycle in the project graph.""" pass def check_for_cycle( existing_edges: list[tuple[uuid.UUID, uuid.UUID]], new_from: uuid.UUID, new_to: uuid.UUID, ) -> bool: """Return True if adding (new_from -> new_to) creates a cycle in the directed graph.""" if new_from == new_to: return True adj: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) for u, v in existing_edges: adj[u].append(v) adj[new_from].append(new_to) visited: set[uuid.UUID] = set() rec_stack: set[uuid.UUID] = set() def dfs(node: uuid.UUID) -> bool: visited.add(node) rec_stack.add(node) for neighbor in adj.get(node, []): if neighbor not in visited: if dfs(neighbor): return True elif neighbor in rec_stack: return True rec_stack.remove(node) return False all_nodes = set(adj.keys()) for node in all_nodes: if node not in visited: if dfs(node): return True return False def calculate_schedule( tasks: list, relations: list, project_start: date | None = None, ) -> dict: """Perform CPM forward and backward pass on tasks according to relations. Returns dict with: - tasks_schedule: {task_id: {"planned_start": date, "planned_end": date, "duration": int, "is_critical": bool, "float_days": int}} - critical_path: list of task_ids """ if not tasks: return {"tasks_schedule": {}, "critical_path": []} task_map = {t.id: t for t in tasks} in_degree: dict[uuid.UUID, int] = {t.id: 0 for t in tasks} adj: dict[uuid.UUID, list] = defaultdict(list) reverse_adj: dict[uuid.UUID, list] = defaultdict(list) for rel in relations: if rel.from_task_id in task_map and rel.to_task_id in task_map: adj[rel.from_task_id].append(rel) reverse_adj[rel.to_task_id].append(rel) in_degree[rel.to_task_id] += 1 # 1. Topological Sort via Kahn's algorithm queue = deque([tid for tid, deg in in_degree.items() if deg == 0]) topo_order: list[uuid.UUID] = [] while queue: u = queue.popleft() topo_order.append(u) for rel in adj[u]: v = rel.to_task_id in_degree[v] -= 1 if in_degree[v] == 0: queue.append(v) if len(topo_order) < len(tasks): # Fallback if a cycle somehow exists: process in default order topo_order = [t.id for t in tasks] # Baseline anchor date base_date = project_start or date_service.today() # 2. Forward Pass (Calculate Early Start ES and Early Finish EF) es: dict[uuid.UUID, date] = {} ef: dict[uuid.UUID, date] = {} durations: dict[uuid.UUID, int] = {} for tid in topo_order: t = task_map[tid] # مدت = فاصلهٔ روزها؛ صفر یعنی شروع و پایان همان روز duration = max(t.duration or 0, 0) durations[tid] = duration # Default start from current task planned_start or project base initial_start = t.planned_start or base_date incoming = reverse_adj.get(tid, []) if not incoming: es[tid] = initial_start else: cand_starts = [initial_start] for rel in incoming: p_id = rel.from_task_id p_ef = ef.get(p_id, initial_start) p_es = es.get(p_id, initial_start) lag = rel.lag_days or 0 rtype = (rel.relation_type or "FS").upper() if rtype == "FS": # Successor starts after Predecessor finishes + lag cand_starts.append(p_ef + timedelta(days=lag)) elif rtype == "SS": # Successor starts after Predecessor starts + lag cand_starts.append(p_es + timedelta(days=lag)) elif rtype == "FF": # Successor finishes after Predecessor finishes + lag -> adjust start needed_ef = p_ef + timedelta(days=lag) cand_starts.append(needed_ef - timedelta(days=duration)) elif rtype == "SF": # Successor finishes after Predecessor starts + lag needed_ef = p_es + timedelta(days=lag) cand_starts.append(needed_ef - timedelta(days=duration)) es[tid] = max(cand_starts) ef[tid] = es[tid] + timedelta(days=duration) # 3. Backward Pass (Calculate Late Start LS, Late Finish LF, Float) project_max_ef = max(ef.values()) if ef else base_date lf: dict[uuid.UUID, date] = {} ls: dict[uuid.UUID, date] = {} for tid in reversed(topo_order): duration = durations[tid] outgoing = adj.get(tid, []) if not outgoing: lf[tid] = project_max_ef else: cand_finishes = [] for rel in outgoing: s_id = rel.to_task_id s_ls = ls.get(s_id, project_max_ef) s_lf = lf.get(s_id, project_max_ef) lag = rel.lag_days or 0 rtype = (rel.relation_type or "FS").upper() if rtype == "FS": cand_finishes.append(s_ls - timedelta(days=lag)) elif rtype == "SS": needed_s_start = s_ls - timedelta(days=lag) cand_finishes.append(needed_s_start + timedelta(days=duration)) elif rtype == "FF": cand_finishes.append(s_lf - timedelta(days=lag)) elif rtype == "SF": needed_s_finish = s_lf - timedelta(days=lag) cand_finishes.append(needed_s_finish + timedelta(days=duration)) lf[tid] = min(cand_finishes) if cand_finishes else project_max_ef ls[tid] = lf[tid] - timedelta(days=duration) # 4. Critical Path Identification (Total Float == 0) critical_path: list[uuid.UUID] = [] tasks_schedule: dict[uuid.UUID, dict] = {} for tid in topo_order: t_es = es[tid] t_ef = ef[tid] t_ls = ls.get(tid, t_es) t_lf = lf.get(tid, t_ef) float_days = (t_ls - t_es).days is_critical = float_days <= 0 if is_critical: critical_path.append(tid) tasks_schedule[tid] = { "planned_start": t_es, "planned_end": t_ef, "duration": durations[tid], "float_days": max(float_days, 0), "is_critical": is_critical, } return { "tasks_schedule": tasks_schedule, "critical_path": critical_path, "project_finish": project_max_ef, } def rollup_parent_tasks(tasks: list) -> dict[uuid.UUID, dict]: """Roll up dates and progress from children to parent tasks.""" parent_map: dict[uuid.UUID, list] = defaultdict(list) task_map = {t.id: t for t in tasks} for t in tasks: if t.parent_id and t.parent_id in task_map: parent_map[t.parent_id].append(t) rollups: dict[uuid.UUID, dict] = {} for pid, children in parent_map.items(): child_starts = [c.planned_start for c in children if c.planned_start] child_ends = [c.planned_end for c in children if c.planned_end] child_progs = [c.progress or 0 for c in children] p_start = min(child_starts) if child_starts else None p_end = max(child_ends) if child_ends else None p_dur = None if p_start and p_end: p_dur = date_service.calculate_duration(p_start, p_end) rollups[pid] = { "planned_start": p_start, "planned_end": p_end, "duration": p_dur, "progress": round(sum(child_progs) / len(child_progs)) if child_progs else 0, } return rollups async def sync_project_lifecycle_dates(db, project_id: uuid.UUID): """Synchronize Project planned_start, planned_end, actual_start, actual_end, and progress from its tasks. Business rules: - planned_start: planned_start of the first task (earliest planned_start) - planned_end: planned_end of the last task (latest planned_end) - actual_start: actual_start of the first task (or earliest actual_start across tasks) - actual_end: actual_end of the last task """ from sqlalchemy import select from app.models import Project, Task project = await db.get(Project, project_id) if not project: return None tasks_result = await db.execute(select(Task).where(Task.project_id == project_id)) tasks = tasks_result.scalars().all() if not tasks: project.planned_start = None project.planned_end = None project.actual_start = None project.actual_end = None project.progress = 0.0 return project valid_starts = [t for t in tasks if t.planned_start is not None] valid_ends = [t for t in tasks if t.planned_end is not None] if valid_starts: first_task = min(valid_starts, key=lambda t: (t.planned_start, t.created_at)) project.planned_start = first_task.planned_start all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None] project.actual_start = first_task.actual_start or (min(all_actual_starts) if all_actual_starts else None) else: project.planned_start = None all_actual_starts = [t.actual_start for t in tasks if t.actual_start is not None] project.actual_start = min(all_actual_starts) if all_actual_starts else None if valid_ends: last_task = max(valid_ends, key=lambda t: (t.planned_end, t.created_at)) project.planned_end = last_task.planned_end project.actual_end = last_task.actual_end else: project.planned_end = None project.actual_end = None progs = [t.progress or 0 for t in tasks] if progs: project.progress = round(sum(progs) / len(progs), 1) return project