gmao/app_new/scheduler/engine.py

334 lines
12 KiB
Python

"""
Planificateur automatique - GMAO College
Genere les taches planifiees en croisant :
- Taches preventives dues (PreventiveTask + LotTask)
- Horaires de travail (WorkSchedule)
- Vacances (CollegeClosure) + absences (PersonalLeave) + formations (Training)
- Contraintes de salle (RoomConstraint)
- Reserve 40% de creneaux pour le curatif
- Regroupe par batiment/zone pour optimiser les deplacements
"""
from datetime import datetime, timezone, date, time, timedelta
from ..extensions import db
from ..core.models.planning import (
WorkSchedule, CollegeClosure, ClosureWorkDay, PersonalLeave, Training,
PreventiveTask, PlanningDay, PlanningItem
)
from ..core.models.maintenance import Intervention, LotTask, Lot
from ..core.models.equipment import Equipment, RoomConstraint, is_room_available
from ..core.models.college import Room, Building
from sqlalchemy import func
def get_working_hours(check_date, user_id=1):
"""Retourne les horaires de travail pour une date donnee.
Prend en compte les vacances (CollegeClosure) et les horaires normaux (WorkSchedule).
Retourne (start_time, end_time, lunch_start, lunch_end) ou None si non travaille.
"""
from ..core.services.planning_service import PlanningService
return PlanningService.get_working_hours(check_date, user_id=user_id)
def is_user_available(check_date, user_id=1):
"""Verifie si l'utilisateur est disponible (pas en conge, pas en formation)."""
from ..core.services.planning_service import PlanningService
reason = PlanningService.maintenance_unavailability(check_date, user_id=user_id)
return (reason is None, reason)
def get_due_tasks(weeks_ahead=4):
"""Recupere toutes les taches preventives dues dans les N prochaines semaines.
Retourne une liste de dicts avec : task, equipment, room, due_date, duration, lot_task
"""
today = date.today()
horizon = today + timedelta(weeks=weeks_ahead)
due_tasks = []
# 1. PreventiveTask avec period_days
for pt in PreventiveTask.query.filter_by(is_active=True).all():
if not pt.period_days:
continue
# Trouver les equipements correspondants
if pt.lot_id:
equipments = Equipment.query.filter_by(lot_id=pt.lot_id).all()
elif pt.category_id:
equipments = Equipment.query.filter_by(category_id=pt.category_id).all()
else:
continue
for eq in equipments:
# Verifier si deja planifie
existing = Intervention.query.filter_by(
preventive_task_id=pt.id,
equipment_id=eq.id,
status='planifiee',
is_deleted=False
).first()
if existing:
continue
# Calculer la date due
last_done = Intervention.query.filter_by(
preventive_task_id=pt.id,
equipment_id=eq.id,
status='terminee',
is_deleted=False
).order_by(Intervention.scheduled_date.desc()).first()
if last_done:
due_date = last_done.scheduled_date + timedelta(days=pt.period_days)
else:
due_date = today # Jamais fait -> dues maintenant
if due_date <= horizon:
due_tasks.append({
'preventive_task': pt,
'equipment': eq,
'room': eq.room,
'due_date': due_date,
'duration': pt.duration_minutes or 60,
'lot_task': None,
})
# 2. LotTask avec jours_entre_interventions
for lt in LotTask.query.filter_by(is_active=True, trigger_type='calendar').all():
if not lt.jours_entre_interventions:
continue
lot = Lot.query.get(lt.lot_id)
if not lot:
continue
equipments = Equipment.query.filter_by(lot_id=lot.id).all()
for eq in equipments:
existing = Intervention.query.filter_by(
lot_task_id=lt.id,
equipment_id=eq.id,
status='planifiee',
is_deleted=False
).first()
if existing:
continue
last_done = Intervention.query.filter_by(
lot_task_id=lt.id,
equipment_id=eq.id,
status='terminee',
is_deleted=False
).order_by(Intervention.scheduled_date.desc()).first()
if last_done:
due_date = last_done.scheduled_date + timedelta(days=lt.jours_entre_interventions)
else:
due_date = today
if due_date <= horizon:
due_tasks.append({
'lot_task': lt,
'equipment': eq,
'room': eq.room,
'due_date': due_date,
'duration': lt.effective_duration(),
'preventive_task': None,
})
# Trier par date due (les plus en retard d'abord)
due_tasks.sort(key=lambda x: x['due_date'])
return due_tasks
def group_by_zone(tasks):
"""Regroupe les taches par batiment/zone pour optimiser les deplacements.
Retourne un dict {building_name: [tasks]}
"""
groups = {}
for t in tasks:
room = t.get('room')
building_name = 'Autre'
if room and hasattr(room, 'building') and room.building:
building_name = room.building.name
elif room:
building_name = f"Salle {room.name}"
else:
building_name = 'Non localise'
if building_name not in groups:
groups[building_name] = []
groups[building_name].append(t)
return groups
def find_slot(check_date, duration_min, room_id, user_id=1, reserved_ratio=0.4):
"""Trouve un creneau disponible pour une tache.
Verifie : horaires de travail, conges, contraintes de salle.
Reserve reserved_ratio (40%) du temps pour le curatif.
Retourne (start_time, end_time) ou None.
"""
wh = get_working_hours(check_date, user_id)
if not wh:
return None
work_start, work_end, lunch_start, lunch_end = wh
available_minutes = 0
if lunch_start and lunch_end:
# Matin + apres-midi
morning = (lunch_start.hour * 60 + lunch_start.minute) - (work_start.hour * 60 + work_start.minute)
afternoon = (work_end.hour * 60 + work_end.minute) - (lunch_end.hour * 60 + lunch_end.minute)
available_minutes = morning + afternoon
else:
available_minutes = (work_end.hour * 60 + work_end.minute) - (work_start.hour * 60 + work_start.minute)
# Reserver 40% pour le curatif
bookable_minutes = int(available_minutes * (1 - reserved_ratio))
# Verifier combien de temps est deja pris ce jour-la
booked = Intervention.query.filter_by(
scheduled_date=check_date,
status='planifiee',
is_deleted=False
).all()
booked_minutes = sum(t.estimated_duration or 60 for t in booked)
remaining = bookable_minutes - booked_minutes
if remaining < duration_min:
return None # Pas assez de place
# Trouver un creneau libre
# Commencer apres le dernier creneau booke
if booked:
last_end = max(
(t.scheduled_end for t in booked if t.scheduled_end),
default=work_start
)
# Si le dernier se termine apres le debut, commencer apres
slot_start = last_end
else:
slot_start = work_start
# Ajuster pour la pause dejeuner
if lunch_start and lunch_end and slot_start < lunch_end:
if slot_start < lunch_start:
# On peut commencer avant la pause, verifier la duree
morning_end = lunch_start
morning_available = (morning_end.hour * 60 + morning_end.minute) - (slot_start.hour * 60 + slot_start.minute)
if morning_available >= duration_min:
slot_end_dt = datetime.combine(check_date, slot_start) + timedelta(minutes=duration_min)
slot_end = slot_end_dt.time()
# Verifier contrainte de salle
if room_id:
available, reason = is_room_available(room_id, check_date, slot_start, slot_end)
if not available:
return None
return (slot_start, slot_end)
# Commencer apres la pause
slot_start = lunch_end
slot_end_dt = datetime.combine(check_date, slot_start) + timedelta(minutes=duration_min)
slot_end = slot_end_dt.time()
if slot_end > work_end:
return None # Depasse la fin du travail
# Verifier contrainte de salle
if room_id:
available, reason = is_room_available(room_id, check_date, slot_start, slot_end)
if not available:
return None
return (slot_start, slot_end)
def run_scheduler(weeks_ahead=4, user_id=1):
"""Lance le planificateur.
- Recupere les taches dues
- Les regroupe par zone
- Pour chaque jour ouvrable, essaie de placer les taches
- Reserve 40% pour le curatif
- Retourne un resume
"""
due_tasks = get_due_tasks(weeks_ahead)
if not due_tasks:
return {'success': True, 'planned': 0, 'skipped': 0, 'errors': [], 'message': 'Aucune tache due'}
# Regrouper par zone
groups = group_by_zone(due_tasks)
planned = 0
skipped = 0
errors = []
# Parcourir les jours a partir d'aujourd'hui
today = date.today()
remaining_tasks = list(due_tasks)
for day_offset in range(weeks_ahead * 7):
check_date = today + timedelta(days=day_offset)
# Verifier disponibilite utilisateur
avail, reason = is_user_available(check_date, user_id)
if not avail:
continue
# Verifier horaires
wh = get_working_hours(check_date, user_id)
if not wh:
continue
# Trier les taches restantes par date due (urgentes d'abord)
remaining_tasks.sort(key=lambda x: x['due_date'])
# Pour chaque tache, essayer de trouver un creneau
tasks_to_remove = []
for t in remaining_tasks:
slot = find_slot(check_date, t['duration'], t['room'].id if t['room'] else None, user_id)
if slot:
start_time, end_time = slot
# Creer l'Intervention au statut planifiee
title = ''
if t['preventive_task']:
title = f"[{check_date}] {t['preventive_task'].name}"[:290]
elif t['lot_task']:
title = f"[{check_date}] {t['lot_task'].tache or 'Tache lot'}"[:290]
else:
title = f"Tache {t['equipment'].name} du {check_date}"[:290]
interv = Intervention(
title=title,
type='preventif',
status='planifiee',
priority='normale',
equipment_id=t['equipment'].id,
room_id=t['room'].id if t['room'] else None,
scheduled_date=check_date,
scheduled_start=start_time,
scheduled_end=end_time,
estimated_duration=float(t['duration']),
preventive_task_id=t['preventive_task'].id if t['preventive_task'] else None,
lot_task_id=t['lot_task'].id if t['lot_task'] else None,
)
db.session.add(interv)
planned += 1
tasks_to_remove.append(t)
for t in tasks_to_remove:
remaining_tasks.remove(t)
if not remaining_tasks:
break
skipped = len(remaining_tasks)
if remaining_tasks:
for t in remaining_tasks[:5]:
errors.append(f"Non planifie: {t['equipment'].name} (du {t['due_date'].strftime('%d/%m')})")
db.session.commit()
return {
'success': True,
'planned': planned,
'skipped': skipped,
'total_due': len(due_tasks),
'errors': errors,
'message': f"{planned} tache(s) planifiee(s), {skipped} non planifiee(s) sur {len(due_tasks)} dues"
}