"""Planning Schedules Routes - GMAO Collège""" from flask import Blueprint, render_template, request, redirect, url_for, flash from flask_login import login_required, current_user from ..extensions import db from ..core.models.planning import ( PreventiveTask, PreventiveTaskConsumable, ScheduledTask, Meter, MeterReading, Consumable, ConsumableUsage, TechnicianAvailability, AdminTask, ZoneAccessRule, WorkSchedule, CollegeClosure, PlanningDay, PlanningItem ) from ..core.models.maintenance import Lot, LotTask, Intervention from ..core.models.equipment import EquipmentCategory from ..core.models.company import Company from datetime import datetime, time as dt_time planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') @planning_bp.route('/') @login_required def index(): """Page principale de planification - calendrier sur 1 an.""" from datetime import date, timedelta from calendar import monthrange from ..core.services.planning_service import PlanningService from ..core.models.planning import PlanningDay, PlanningItem, ClosureWorkDay, WorkSchedule, TimeEntry, PersonalLeave from ..core.models.settings import AppSettings from ..core.services.planning_service import get_french_public_holidays # Récupérer l'année demandée ou année courante mode = request.args.get('mode') saved_mode = AppSettings.get(f'planning_calendar_mode_{current_user.id}', 'calendar') if mode not in ('calendar', 'school'): mode = saved_mode if saved_mode in ('calendar', 'school') else 'calendar' else: AppSettings.set(f'planning_calendar_mode_{current_user.id}', mode) year = request.args.get('year', date.today().year, type=int) if mode == 'school' and 'year' not in request.args: year = date.today().year if date.today().month >= 9 else date.today().year - 1 display_start = date(year, 9, 1) if mode == 'school' else date(year, 1, 1) display_end = date(year + 1, 8, 31) if mode == 'school' else date(year, 12, 31) # Préparer les données du calendrier. Le calendrier principal est la # source unique : il rassemble les interventions et les échéances # préventives qui n'ont pas encore été converties en intervention. months_data = [] months_names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'] # Récupérer les vacances closures = CollegeClosure.query.filter( CollegeClosure.start_date <= display_end, CollegeClosure.end_date >= display_start ).all() permanence_dates = {item.work_date for item in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= display_start, ClosureWorkDay.work_date <= display_end).all()} permanence_map = {item.work_date: item for item in ClosureWorkDay.query.filter(ClosureWorkDay.work_date >= display_start, ClosureWorkDay.work_date <= display_end).all()} assignments = {item.day_of_week: item for item in WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all()} time_entries = {item.work_date: item for item in TimeEntry.query.filter_by(user_id=current_user.id).filter(TimeEntry.work_date >= display_start, TimeEntry.work_date <= display_end).all()} personal_leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= display_start, PersonalLeave.start_date <= display_end).all() leave_dates = {leave.start_date + timedelta(days=offset) for leave in personal_leaves for offset in range((leave.end_date - leave.start_date).days + 1)} holiday_dates = {item[0] for item in get_french_public_holidays(year)} if mode == 'school': holiday_dates |= {item[0] for item in get_french_public_holidays(year + 1)} # Fonction pour vérifier si une date est en vacances def is_vacation(d): for c in closures: if c.start_date <= d <= c.end_date: return c.name return None # Récupérer les congés personnels leaves = TechnicianAvailability.query.filter( TechnicianAvailability.date >= display_start, TechnicianAvailability.date <= display_end ).all() leave_dates = {l.date: l for l in leaves} year_start = display_start year_end = display_end terminal_intervention_statuses = ('terminee', 'cloturee', 'annulee', 'ignoree') interventions = Intervention.query.filter( Intervention.scheduled_date >= year_start, Intervention.scheduled_date <= year_end, Intervention.is_deleted.is_(False), ~Intervention.status.in_(terminal_intervention_statuses), ).order_by(Intervention.scheduled_date, Intervention.scheduled_start).all() scheduled_tasks = ScheduledTask.query.filter( ScheduledTask.scheduled_date >= year_start, ScheduledTask.scheduled_date <= year_end, ScheduledTask.status.in_(('planned', 'in_progress', 'suspended', 'postponed')), # Une échéance convertie est déjà représentée par son intervention. ScheduledTask.intervention_id.is_(None), ).order_by(ScheduledTask.scheduled_date, ScheduledTask.scheduled_start).all() events_by_date = {} for intervention in interventions: events_by_date.setdefault(intervention.scheduled_date, []).append({ 'title': intervention.title or 'Intervention', 'type': intervention.type or 'curatif', 'status': intervention.status, 'start_time': intervention.scheduled_start, 'end_time': intervention.scheduled_end, 'duration': intervention.estimated_duration, 'room_name': intervention.room.name if intervention.room else None, 'url': url_for('interventions.detail', id=intervention.id), 'source': 'Intervention', 'color': 'danger' if intervention.type == 'curatif' else 'primary', }) for task in scheduled_tasks: title = 'Tâche préventive' if task.lot_task and task.lot_task.tache: title = task.lot_task.tache elif task.preventive_task and task.preventive_task.name: title = task.preventive_task.name elif task.equipment: title = f'Intervention sur {task.equipment.name}' room = task.room or (task.equipment.effective_room if task.equipment else None) events_by_date.setdefault(task.scheduled_date, []).append({ 'title': title, 'type': 'preventive', 'status': task.status, 'start_time': task.scheduled_start, 'end_time': task.scheduled_end, 'duration': task.estimated_duration, 'room_name': room.name if room else None, 'url': url_for('planning.scheduled_detail', id=task.id), 'source': 'Échéance préventive', 'color': 'info', }) month_order = list(range(9, 13)) + list(range(1, 9)) if mode == 'school' else list(range(1, 13)) for month in month_order: actual_year = year if mode != 'school' or month >= 9 else year + 1 # Nombre de jours dans le mois days_in_month = monthrange(actual_year, month)[1] # Jour de la semaine du premier jour (0=lundi, 6=dimanche) first_weekday = date(actual_year, month, 1).weekday() weeks = [] month_planned_minutes = 0 month_overtime_minutes = 0 month_delay_minutes = 0 week = [None] * first_weekday # Cases vides avant le 1er jour for day in range(1, days_in_month + 1): d = date(actual_year, month, day) vacation = is_vacation(d) leave = d in leave_dates weekday = d.weekday() planned_minutes = 0 permanence = permanence_map.get(d) if permanence: planned_minutes = max((permanence.end_time.hour * 60 + permanence.end_time.minute) - (permanence.start_time.hour * 60 + permanence.start_time.minute), 0) if permanence.lunch_start and permanence.lunch_end: planned_minutes -= (permanence.lunch_end.hour * 60 + permanence.lunch_end.minute) - (permanence.lunch_start.hour * 60 + permanence.lunch_start.minute) elif not holiday_dates.__contains__(d) and not leave and not vacation and weekday < 5: assignment = assignments.get(weekday) if assignment: source = assignment.template if assignment.template and assignment.template.is_active else assignment planned_minutes = max((source.end_time.hour * 60 + source.end_time.minute) - (source.start_time.hour * 60 + source.start_time.minute), 0) if source.lunch_start and source.lunch_end: planned_minutes -= (source.lunch_end.hour * 60 + source.lunch_end.minute) - (source.lunch_start.hour * 60 + source.lunch_start.minute) entry = time_entries.get(d) actual_minutes = entry.actual_minutes if entry else 0 month_planned_minutes += planned_minutes if entry and entry.entry_type == 'supplementaire': month_overtime_minutes += actual_minutes or 0 elif entry and entry.entry_type == 'retard': month_delay_minutes += actual_minutes or 0 fmt = lambda minutes: f"{minutes // 60}h{minutes % 60:02d}" deviation_hours = None deviation_class = None if entry and entry.entry_type == 'retard' and actual_minutes: deviation_hours, deviation_class = f"-{fmt(actual_minutes)}", 'text-danger' elif entry and entry.entry_type == 'supplementaire' and actual_minutes: deviation_hours, deviation_class = f"+{fmt(actual_minutes)}", 'text-success' week.append({ 'day': day, 'date': d, 'is_weekend': weekday >= 5, 'is_vacation': vacation is not None, 'vacation_name': vacation, 'is_holiday': d in holiday_dates, 'is_permanence': d in permanence_dates, 'is_leave': leave, 'leave_type': 'congé' if leave else None, 'worked_hours': fmt(actual_minutes), 'planned_hours': fmt(planned_minutes), 'deviation_hours': deviation_hours, 'deviation_class': deviation_class, 'events': events_by_date.get(d, []), }) # Si on atteint dimanche (fin de semaine), nouvelle semaine if weekday == 6 or day == days_in_month: # Compléter la semaine si nécessaire while len(week) < 7: week.append(None) weeks.append(week) week = [] months_data.append({ 'name': months_names[month - 1], 'year': actual_year, 'weeks': weeks, 'planned_hours': fmt(month_planned_minutes), 'overtime_hours': fmt(month_overtime_minutes), 'delay_hours': fmt(month_delay_minutes), }) # Statistiques stats = { 'preventive_tasks': len(scheduled_tasks), 'curative_interventions': sum(1 for i in interventions if i.type == 'curatif'), 'admin_tasks': AdminTask.query.filter_by(is_active=True).count(), 'pending_items': PlanningItem.query.filter_by(status='planned').count(), } return render_template('planning/calendar.html', year=year, mode=mode, months=months_data, stats=stats ) @planning_bp.route('/day/') @login_required def day(date_str): """Afficher le détail d'une journée.""" from datetime import datetime from ..core.services.planning_service import PlanningService try: d = datetime.strptime(date_str, '%Y-%m-%d').date() except ValueError: flash('Date invalide', 'error') return redirect(url_for('planning.index')) # Récupérer le planning du jour schedule = PlanningService.get_day_schedule(d) # Récupérer les tâches planifiées pour ce jour scheduled_tasks = ScheduledTask.query.filter( ScheduledTask.scheduled_date == d, ScheduledTask.status.in_(('planned', 'in_progress', 'suspended', 'postponed')), ScheduledTask.intervention_id.is_(None), ).all() return render_template('planning/day.html', date=d, schedule=schedule, tasks=scheduled_tasks ) @planning_bp.route('/tasks') @login_required def tasks(): """Liste des tâches préventives.""" from app_new.core.models.maintenance import LotTask tasks = LotTask.query.join(Lot).order_by(Lot.name, LotTask.num_tache).all() lots = Lot.query.order_by(Lot.name).all() categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all() return render_template('planning/tasks.html', tasks=tasks, lots=lots, categories=categories) @planning_bp.route('/tasks//edit', methods=['GET', 'POST']) @login_required def edit_task(id): """Modifier une tâche préventive.""" from app_new.core.models.maintenance import LotTask task = LotTask.query.get_or_404(id) if request.method == 'POST': duree = request.form.get('duree_minutes') task.duree_minutes = int(duree) if duree and duree.isdigit() else None task.gestionnaire = request.form.get('gestionnaire') or None task.periodicite = request.form.get('periodicite') or None task.jours_entre_interventions = int(request.form.get('jours_entre_interventions')) if request.form.get('jours_entre_interventions') and request.form.get('jours_entre_interventions').isdigit() else None task.type_maintenance = request.form.get('type_maintenance') or None task.trigger_type = request.form.get('trigger_type', 'calendar') task.trigger_event = request.form.get('trigger_event') or None task.meter_threshold = request.form.get('meter_threshold', type=float) task.season_start_month = request.form.get('season_start_month', type=int) task.season_end_month = request.form.get('season_end_month', type=int) task.advance_days = request.form.get('advance_days', type=int, default=0) task.is_active = request.form.get('is_active') == 'on' task.contrat = request.form.get('contrat') == 'on' db.session.commit() flash('Tâche mise à jour', 'success') return redirect(url_for('planning.tasks')) return render_template('planning/edit_task.html', task=task) @planning_bp.route('/tasks//generate', methods=['POST']) @login_required def generate_task(id): from app_new.core.models.maintenance import LotTask from app_new.core.services.maintenance_engine import generate_due_tasks task = LotTask.query.get_or_404(id) created = generate_due_tasks(task, event=(request.form.get('event') or '').strip() or None) flash(f'{len(created)} échéance(s) créée(s), sans doublon.', 'success' if created else 'info') return redirect(url_for('planning.tasks')) @planning_bp.route('/tasks/new', methods=['GET', 'POST']) @login_required def new_task(): """Créer une nouvelle tâche préventive.""" if request.method == 'POST': task = LotTask( lot_id=request.form.get('lot_id', type=int), num_tache=request.form.get('task_number'), tache=request.form.get('name'), periodicite=request.form.get('periodicite'), jours_entre_interventions=request.form.get('period_days', type=int), duree_minutes=request.form.get('duration_minutes', type=int, default=30), contrat=request.form.get('has_contract') == 'on', gestionnaire=request.form.get('manager'), trigger_type=request.form.get('trigger_type', 'calendar'), trigger_event=request.form.get('trigger_event') or None, meter_threshold=request.form.get('meter_threshold', type=float), season_start_month=request.form.get('season_start_month', type=int), season_end_month=request.form.get('season_end_month', type=int), is_active=True ) db.session.add(task) db.session.commit() flash('Tâche préventive créée', 'success') return redirect(url_for('planning.tasks')) lots = Lot.query.order_by(Lot.name).all() categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all() companies = Company.query.order_by(Company.name).all() return render_template('planning/task_form.html', title='Nouvelle tâche préventive', task=None, lots=lots, categories=categories, companies=companies) @planning_bp.route('/scheduled') @login_required def scheduled(): """Liste des tâches planifiées.""" status = request.args.get('status', 'all') date_filter = request.args.get('date') query = ScheduledTask.query.filter(ScheduledTask.equipment_id.isnot(None)) if status != 'all': query = query.filter_by(status=status) if date_filter: query = query.filter_by(scheduled_date=datetime.strptime(date_filter, '%Y-%m-%d').date()) tasks = query.order_by(ScheduledTask.scheduled_date, ScheduledTask.scheduled_start).all() return render_template('planning/scheduled.html', tasks=tasks, status=status) @planning_bp.route('/scheduled/reconcile', methods=['POST']) @login_required def reconcile_scheduled(): from app_new.core.services.maintenance_engine import reconcile_planned_tasks changed = reconcile_planned_tasks() flash(f'{len(changed)} échéance(s) déplacée(s) vers un jour travaillé.', 'success' if changed else 'info') return redirect(url_for('planning.scheduled')) @planning_bp.route('/scheduled/') @login_required def scheduled_detail(id): """Détail d'une tâche planifiée.""" task = ScheduledTask.query.get_or_404(id) return render_template('planning/scheduled_detail.html', task=task) @planning_bp.route('/meters') @login_required def meters(): """Liste des compteurs.""" meters = Meter.query.order_by(Meter.equipment_id).all() return render_template('planning/meters.html', meters=meters) @planning_bp.route('/meters/new', methods=['GET', 'POST']) @login_required def new_meter(): """Créer un nouveau compteur.""" if request.method == 'POST': meter = Meter( equipment_id=request.form.get('equipment_id', type=int), name=request.form.get('name'), meter_type=request.form.get('meter_type', 'hours'), unit=request.form.get('unit', 'h'), current_value=request.form.get('current_value', type=float, default=0), initial_value=request.form.get('initial_value', type=float, default=0), warning_threshold=request.form.get('warning_threshold', type=float), critical_threshold=request.form.get('critical_threshold', type=float), maintenance_interval=request.form.get('maintenance_interval', type=float), is_active=True ) db.session.add(meter) db.session.commit() flash('Compteur créé', 'success') return redirect(url_for('planning.meters')) from ..core.models.equipment import Equipment equipments = Equipment.query.filter_by(is_group=False).order_by(Equipment.name).all() return render_template('planning/meter_form.html', title='Nouveau compteur', meter=None, equipments=equipments) @planning_bp.route('/consumables') @login_required def consumables(): """Liste des consommables.""" consumables = Consumable.query.order_by(Consumable.category, Consumable.name).all() return render_template('planning/consumables.html', consumables=consumables) @planning_bp.route('/consumables/new', methods=['GET', 'POST']) @login_required def new_consumable(): """Créer un nouveau consommable.""" if request.method == 'POST': consumable = Consumable( name=request.form.get('name'), reference=request.form.get('reference'), category=request.form.get('category'), quantity=request.form.get('quantity', type=int, default=0), min_quantity=request.form.get('min_quantity', type=int, default=0), unit=request.form.get('unit', 'pièce'), unit_price=request.form.get('unit_price', type=float), supplier_id=request.form.get('supplier_id', type=int) or None, storage_location=request.form.get('storage_location'), is_active=True ) db.session.add(consumable) db.session.commit() flash('Consommable créé', 'success') return redirect(url_for('planning.consumables')) companies = Company.query.order_by(Company.name).all() return render_template('planning/consumable_form.html', title='Nouveau consommable', consumable=None, companies=companies) @planning_bp.route('/consumables//edit', methods=['GET', 'POST']) @login_required def edit_consumable(id): consumable = Consumable.query.get_or_404(id) if request.method == 'POST': consumable.name = (request.form.get('name') or '').strip() consumable.reference = request.form.get('reference') or None consumable.category = request.form.get('category') or None consumable.quantity = max(0, request.form.get('quantity', type=int, default=0)) consumable.min_quantity = max(0, request.form.get('min_quantity', type=int, default=0)) consumable.unit = request.form.get('unit') or 'pièce' consumable.unit_price = request.form.get('unit_price', type=float) consumable.supplier_id = request.form.get('supplier_id', type=int) or None consumable.storage_location = request.form.get('storage_location') or request.form.get('location') or None db.session.commit() flash('Consommable mis à jour', 'success') return redirect(url_for('planning.consumables')) companies = Company.query.order_by(Company.name).all() return render_template('planning/consumable_form.html', title='Modifier le consommable', consumable=consumable, companies=companies)