gmao/app_new/planning/schedules.py
root 923f3a31a5
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Afficher les heures prévues et réalisées au calendrier
2026-08-20 20:47:28 +00:00

468 lines
21 KiB
Python

"""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 = []
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
fmt = lambda minutes: f"{minutes // 60}h{minutes % 60:02d}"
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),
'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
})
# 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/<date_str>')
@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/<int:id>/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/<int:id>/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/<int:id>')
@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/<int:id>/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)