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
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user