"""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 ..core.models.college import Room, RoomSchedule from ..core.models.user import User from datetime import datetime, date, time as dt_time planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') @planning_bp.route('/work-schedules', methods=['GET', 'POST']) @login_required def work_schedules(): """Configuration simple des horaires de chaque technicien. Une ligne représente une journée ; la pause est facultative et sépare la matinée de l'après-midi dans le DayPlanner. Les utilisateurs ne peuvent modifier que leurs propres horaires, les administrateurs peuvent gérer l'équipe. """ from datetime import datetime users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() target_id = request.values.get('user_id', type=int) or current_user.id if target_id != current_user.id and not current_user.is_admin(): target_id = current_user.id target = db.session.get(User, target_id) or current_user year = request.values.get('academic_year', date.today().year, type=int) if request.method == 'POST': for day in range(7): def parse(field): raw = request.form.get(field) return datetime.strptime(raw, '%H:%M').time() if raw else None start, end = parse(f'start_{day}'), parse(f'end_{day}') lunch_start, lunch_end = parse(f'lunch_start_{day}'), parse(f'lunch_end_{day}') row = WorkSchedule.query.filter_by(user_id=target.id, day_of_week=day, academic_year=year).first() if not start or not end: if row: row.is_active = False continue if end <= start or (lunch_start and (not lunch_end or not start <= lunch_start < lunch_end <= end)): flash(f'Horaires invalides pour le jour {day + 1}.', 'danger') return redirect(url_for('planning.work_schedules', user_id=target.id, academic_year=year)) if not row: row = WorkSchedule(user_id=target.id, day_of_week=day, academic_year=year) db.session.add(row) row.start_time, row.end_time = start, end row.lunch_start, row.lunch_end, row.is_active = lunch_start, lunch_end, True db.session.commit() flash(f'Horaires de {target.full_name or target.username} enregistrés.', 'success') rows = {row.day_of_week: row for row in WorkSchedule.query.filter_by(user_id=target.id, academic_year=year, is_active=True).all()} return render_template('planning/work_schedules.html', users=users, target=target, rows=rows, year=year, days=['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']) @planning_bp.route('/my-day') @login_required def my_day(): """Vue quotidienne : une proposition, jamais une écriture automatique.""" from datetime import timedelta from ..core.services.day_planner import DayPlanner raw_date = request.args.get('date') try: target_date = date.fromisoformat(raw_date) if raw_date else date.today() except ValueError: flash('La date demandée est invalide.', 'warning') target_date = date.today() candidates = DayPlanner.from_database(target_date, user_id=current_user.id) windows = DayPlanner.work_windows(target_date, user_id=current_user.id) alternatives = { (item.source_type, item.source_id): DayPlanner.alternative_slots( target_date, item, candidates, user_id=current_user.id ) for item in candidates if item.status in ('à replanifier', 'proposé', 'conflit') } execution_states = {} for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all(): execution_states[task.id] = { 'status': task.status, 'realized': task.realized_minutes, 'remaining': task.remaining_minutes, } return render_template( 'planning/my_day.html', target_date=target_date, candidates=candidates, work_windows=windows, previous_date=target_date - timedelta(days=1), next_date=target_date + timedelta(days=1), hours_configured=bool(windows), alternatives=alternatives, execution_states=execution_states, ) @planning_bp.route('/my-day/reschedule', methods=['POST']) @login_required def my_day_reschedule(): """Valide un seul créneau choisi par l'utilisateur.""" from ..core.services.day_planner import DayPlanner from ..core.models.planning import ScheduledTask source_type = request.form.get('source_type', '') source_id = request.form.get('source_id', type=int) raw_date = request.form.get('date', '') raw_slot = request.form.get('slot', '') raw_start, raw_end = (raw_slot.split('|', 1) if '|' in raw_slot else ('', '')) try: target_date = date.fromisoformat(raw_date) start_time = dt_time.fromisoformat(raw_start) end_time = dt_time.fromisoformat(raw_end) except (TypeError, ValueError): flash('Le créneau choisi est invalide.', 'danger') return redirect(url_for('planning.my_day')) if not source_id or end_time <= start_time: flash('Le créneau choisi est invalide.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) candidates = DayPlanner.from_database(target_date, user_id=current_user.id) candidate = next((item for item in candidates if item.source_type == source_type and item.source_id == source_id), None) if not candidate or candidate.status == 'terminée': flash('Cette tâche n’est plus replanifiable.', 'warning') return redirect(url_for('planning.my_day', date=target_date.isoformat())) duration = int(candidate.duration_minutes or 1) if (end_time.hour * 60 + end_time.minute) - (start_time.hour * 60 + start_time.minute) != duration: flash('La durée choisie ne correspond pas à la durée de la tâche.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) windows = DayPlanner.work_windows(target_date, user_id=current_user.id) if not any(window.start <= start_time and end_time <= window.end for window in windows): flash('Ce créneau est en dehors des horaires de travail ou pendant la pause.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) others = [item for item in candidates if item is not candidate and item.proposed_start and item.proposed_end] if any(item.proposed_start < end_time and item.proposed_end > start_time for item in others): flash('Ce créneau est déjà pris par une autre tâche fixe ou proposée.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) if candidate.room_id: from ..core.services.room_planning import room_is_available if not room_is_available(candidate.room_id, datetime.combine(target_date, start_time), datetime.combine(target_date, end_time)): flash('La salle est occupée sur ce créneau.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) if source_type == 'scheduled_task': task = ScheduledTask.query.get(source_id) if not task or (task.assigned_to_id not in (None, current_user.id)): flash('Cette tâche n’est pas accessible.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) task.scheduled_date, task.scheduled_start, task.scheduled_end = target_date, start_time, end_time task.status = 'planned' elif source_type == 'intervention': task = Intervention.query.get(source_id) if not task or (task.technician_id not in (None, current_user.id) and task.assigned_to_id not in (None, current_user.id)): flash('Cette intervention n’est pas accessible.', 'danger') return redirect(url_for('planning.my_day', date=target_date.isoformat())) task.scheduled_date, task.scheduled_start, task.scheduled_end = target_date, start_time, end_time else: flash('Cette catégorie ne peut pas encore être replanifiée depuis Ma journée.', 'info') return redirect(url_for('planning.my_day', date=target_date.isoformat())) db.session.commit() flash('Le nouveau créneau a été enregistré.', 'success') return redirect(url_for('planning.my_day', date=target_date.isoformat())) def _execution_redirect(task): return redirect(url_for('planning.my_day', date=(task.scheduled_date or date.today()).isoformat())) @planning_bp.route('/scheduled//start', methods=['POST']) @login_required def scheduled_start(task_id): task = ScheduledTask.query.get_or_404(task_id) if task.assigned_to_id not in (None, current_user.id): flash('Cette tâche ne vous est pas attribuée.', 'danger') return _execution_redirect(task) from ..core.services.task_execution import start_task try: start_task(task, technician_id=current_user.id) db.session.commit() flash('Tâche démarrée.', 'success') except ValueError as exc: flash(str(exc), 'warning') return _execution_redirect(task) @planning_bp.route('/scheduled//pause', methods=['POST']) @login_required def scheduled_pause(task_id): task = ScheduledTask.query.get_or_404(task_id) from ..core.services.task_execution import pause_task try: pause_task(task, reason=request.form.get('reason', 'autre'), note=request.form.get('note')) db.session.commit() flash('Tâche mise en pause ; le temps réalisé est conservé.', 'success') except ValueError as exc: flash(str(exc), 'warning') return _execution_redirect(task) @planning_bp.route('/scheduled//resume', methods=['POST']) @login_required def scheduled_resume(task_id): task = ScheduledTask.query.get_or_404(task_id) from ..core.services.task_execution import resume_task try: resume_task(task, technician_id=current_user.id) db.session.commit() flash('Tâche reprise.', 'success') except ValueError as exc: flash(str(exc), 'warning') return _execution_redirect(task) @planning_bp.route('/scheduled//complete', methods=['POST']) @login_required def scheduled_complete(task_id): task = ScheduledTask.query.get_or_404(task_id) from ..core.services.task_execution import complete_task try: complete_task(task) db.session.commit() flash('Tâche terminée.', 'success') except ValueError as exc: flash(str(exc), 'warning') return _execution_redirect(task) @planning_bp.route('/rooms') @login_required def room_schedules(): """Emplois du temps internes des salles, toutes provenances confondues.""" from ..core.services.room_planning import active_room_schedules room_id = request.args.get('room_id', type=int) rooms = Room.query.order_by(Room.name).all() schedules = active_room_schedules(room_id) if room_id else [] return render_template('planning/room_schedules.html', rooms=rooms, schedules=schedules, selected_room_id=room_id) @planning_bp.route('/rooms/new', methods=['GET', 'POST']) @login_required def room_schedule_new(): rooms = Room.query.order_by(Room.name).all() if request.method == 'POST': try: room_id = int(request.form['room_id']) week_start = date.fromisoformat(request.form['week_start']) day_of_week = int(request.form['day_of_week']) start_time = dt_time.fromisoformat(request.form['start_time']) end_time = dt_time.fromisoformat(request.form['end_time']) except (KeyError, TypeError, ValueError): flash('Salle, date et horaires sont obligatoires.', 'danger') return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None), 400 if (start_time >= end_time or not Room.query.get(room_id) or day_of_week < 0 or day_of_week > 6): flash('Vérifiez la salle et l’ordre des horaires.', 'danger') return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None), 400 schedule = RoomSchedule( room_id=room_id, week_start=week_start, day_of_week=day_of_week, start_time=start_time, end_time=end_time, subject=(request.form.get('subject') or '').strip() or None, teacher=(request.form.get('teacher') or '').strip() or None, class_name=(request.form.get('class_name') or '').strip() or None, course_name=(request.form.get('course_name') or '').strip() or None, event_type=request.form.get('event_type') or 'cours', source='manual', protected_from_sync=request.form.get('protected_from_sync') == 'on', ) db.session.add(schedule) db.session.commit() flash('Créneau de salle enregistré.', 'success') return redirect(url_for('planning.room_schedules', room_id=room_id)) return render_template('planning/room_schedule_form.html', rooms=rooms, schedule=None) @planning_bp.route('/rooms//protect', methods=['POST']) @login_required def room_schedule_protect(id): schedule = RoomSchedule.query.get_or_404(id) schedule.protected_from_sync = request.form.get('protected_from_sync') == 'on' db.session.commit() flash('Protection du créneau mise à jour.', 'success') return redirect(url_for('planning.room_schedules', room_id=schedule.room_id)) @planning_bp.route('/rooms//disable', methods=['POST']) @login_required def room_schedule_disable(id): """Désactive explicitement un créneau sans supprimer son historique.""" schedule = RoomSchedule.query.get_or_404(id) schedule.resolution_status = 'disabled' db.session.commit() flash('Créneau désactivé ; son historique est conservé.', 'success') return redirect(url_for('planning.room_schedules', room_id=schedule.room_id)) @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.name).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': from ..core.services.meter_service import create_meter, MeterDomainError try: meter = create_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), ) except MeterDomainError as exc: flash(str(exc), 'danger') return redirect(url_for('planning.new_meter')) 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)