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
299 lines
10 KiB
Python
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
|
|
|