Files
ERP/pm/backend/app/scheduling_service.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

299 lines
10 KiB
Python

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