334 lines
12 KiB
Python
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"
|
|
}
|