feat(planning): add deterministic daily proposal

This commit is contained in:
root 2026-08-23 10:13:47 +00:00
parent 3c4ef00d21
commit de13fd1b22
7 changed files with 407 additions and 0 deletions

View file

@ -0,0 +1,260 @@
"""Proposition déterministe d'une journée de travail.
Ce service ne persiste jamais une proposition. Il transforme les sources
existantes (tâches planifiées, interventions et tâches administratives) en
objets homogènes, puis cherche des créneaux compatibles avec les horaires de
travail et les occupations internes des salles.
"""
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Iterable, Optional
from .planning_service import PlanningService
from .room_planning import room_is_available
TYPE_LABELS = {
"preventive": "Maintenance préventive",
"curative": "Intervention",
"admin": "Administratif",
"external_company": "Entreprise extérieure",
"emergency": "Urgence",
}
CONSTRAINT_LABELS = {
"fixed": "Horaire fixe",
"urgent": "Urgent",
"deadline": "À réaliser avant une échéance",
"flexible": "Déplaçable",
}
@dataclass
class PlanningCandidate:
source_type: str
source_id: int
title: str
description: str = ""
task_type: str = "preventive"
priority: int = 3
constraint: str = "flexible"
target_date: Optional[date] = None
earliest_start: Optional[time] = None
latest_end: Optional[time] = None
duration_minutes: int = 30
room_id: Optional[int] = None
room_name: Optional[str] = None
zone_name: Optional[str] = None
building_name: Optional[str] = None
assigned_to: Optional[int] = None
status: str = "à planifier"
fixed_start: Optional[time] = None
fixed_end: Optional[time] = None
explanation: str = ""
proposed_start: Optional[time] = field(default=None, compare=False)
proposed_end: Optional[time] = field(default=None, compare=False)
@property
def type_label(self):
return TYPE_LABELS.get(self.task_type, self.task_type)
@property
def constraint_label(self):
return CONSTRAINT_LABELS.get(self.constraint, self.constraint)
@property
def location_label(self):
parts = [self.building_name, self.zone_name, self.room_name]
return " > ".join(part for part in parts if part) or "Localisation non renseignée"
@dataclass
class WorkWindow:
start: time
end: time
def _minutes(value: time) -> int:
return value.hour * 60 + value.minute
def _as_time(value: int) -> time:
return time(value // 60, value % 60)
def _overlap(a_start: time, a_end: time, b_start: time, b_end: time) -> bool:
return a_start < b_end and a_end > b_start
class DayPlanner:
"""Construit une proposition sans appeler aucune intégration externe."""
@staticmethod
def work_windows(day: date, user_id: Optional[int] = None) -> list[WorkWindow]:
# Le planificateur ne fabrique pas silencieusement une journée type.
# Les anciennes vues peuvent conserver leur comportement historique,
# mais « Ma journée » exige une configuration explicite des horaires.
from ..models.planning import WorkSchedule, WorkScheduleTemplate
query = WorkSchedule.query.filter_by(day_of_week=day.weekday(), is_active=True)
if user_id is not None:
query = query.filter(WorkSchedule.user_id == user_id)
configured = query.first() is not None
if not configured and user_id is not None:
configured = WorkScheduleTemplate.query.filter_by(user_id=user_id, is_active=True).first() is not None
if not configured:
return []
hours = PlanningService.get_working_hours(day, user_id=user_id)
if not hours:
return []
start, end, lunch_start, lunch_end = hours
windows = [WorkWindow(start, end)]
if lunch_start and lunch_end and lunch_start < lunch_end:
windows = []
if start < lunch_start:
windows.append(WorkWindow(start, lunch_start))
if lunch_end < end:
windows.append(WorkWindow(lunch_end, end))
return windows
@staticmethod
def _slot_available(day, candidate, start, end, occupied):
if any(_overlap(start, end, item_start, item_end) for item_start, item_end, _ in occupied):
return False
if candidate.room_id and not room_is_available(
candidate.room_id,
datetime.combine(day, start),
datetime.combine(day, end),
):
return False
if candidate.earliest_start and start < candidate.earliest_start:
return False
if candidate.latest_end and end > candidate.latest_end:
return False
return True
@classmethod
def propose(cls, day: date, candidates: Iterable[PlanningCandidate], user_id=None):
candidates = list(candidates)
windows = cls.work_windows(day, user_id)
if not windows:
for candidate in candidates:
candidate.explanation = "Aucun horaire de travail disponible pour cette journée."
return candidates
fixed = [c for c in candidates if c.fixed_start and c.fixed_end]
flexible = [c for c in candidates if c not in fixed]
occupied = []
for candidate in sorted(fixed, key=lambda item: (_minutes(item.fixed_start), item.source_type, item.source_id)):
candidate.proposed_start = candidate.fixed_start
candidate.proposed_end = candidate.fixed_end
candidate.status = "planifié"
candidate.explanation = "Horaire fixe conservé."
occupied.append((candidate.fixed_start, candidate.fixed_end, candidate))
# Les urgences, échéances et priorités passent avant le simple
# regroupement géographique. Le tri reste stable et déterministe.
flexible.sort(key=lambda item: (
0 if item.constraint == "urgent" else 1,
0 if item.constraint == "deadline" else 1,
item.priority,
item.latest_end or time(23, 59),
item.building_name or "",
item.zone_name or "",
item.room_name or "",
item.source_type,
item.source_id,
))
previous = None
for candidate in flexible:
duration = max(int(candidate.duration_minutes or 1), 1)
chosen = None
for window in windows:
cursor = _minutes(window.start)
window_end = _minutes(window.end)
while cursor + duration <= window_end:
start, end = _as_time(cursor), _as_time(cursor + duration)
if cls._slot_available(day, candidate, start, end, occupied):
chosen = (start, end)
break
cursor += 5
if chosen:
break
if chosen:
candidate.proposed_start, candidate.proposed_end = chosen
candidate.status = "proposé"
if previous and previous.building_name == candidate.building_name and previous.zone_name == candidate.zone_name:
reason = "Vous êtes déjà dans la même zone"
elif candidate.room_id:
reason = "salle disponible et durée compatible"
else:
reason = "créneau de travail disponible"
candidate.explanation = reason + (" ; échéance prioritaire" if candidate.constraint == "deadline" else "")
occupied.append((chosen[0], chosen[1], candidate))
previous = candidate
else:
candidate.status = "à replanifier"
candidate.explanation = "Aucun créneau compatible aujourd'hui."
return sorted(candidates, key=lambda item: (_minutes(item.proposed_start) if item.proposed_start else 9999, item.source_type, item.source_id))
@classmethod
def from_database(cls, day: date, user_id=None):
"""Charge les sources accessibles sans créer de nouvelle table."""
from ..models.planning import ScheduledTask, AdminTask
from ..models.maintenance import Intervention
candidates = []
tasks = ScheduledTask.query.filter(
ScheduledTask.scheduled_date == day,
ScheduledTask.status.in_(("planned", "in_progress", "suspended", "postponed")),
ScheduledTask.intervention_id.is_(None),
).all()
for task in tasks:
if user_id and task.assigned_to_id not in (None, user_id):
continue
room = task.room or (task.equipment.effective_room if task.equipment else None)
candidates.append(PlanningCandidate(
source_type="scheduled_task", source_id=task.id,
title=(task.preventive_task.name if task.preventive_task else task.lot_task.tache if task.lot_task else "Maintenance préventive"),
task_type="preventive", priority=2 if task.lot_task_id else 4,
constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible",
target_date=day, duration_minutes=int(task.estimated_duration or 30),
room_id=task.room_id or (room.id if room else None), room_name=room.name if room else None,
zone_name=room.zone.name if room and room.zone else None,
building_name=room.building.name if room and room.building else None,
assigned_to=task.assigned_to_id, status=task.status,
fixed_start=task.scheduled_start, fixed_end=task.scheduled_end,
))
interventions = Intervention.query.filter(
Intervention.scheduled_date == day,
Intervention.is_deleted.is_(False),
~Intervention.status.in_(("terminee", "cloturee", "annulee", "ignoree")),
).all()
for intervention in interventions:
if user_id and intervention.technician_id not in (None, user_id) and intervention.assigned_to_id not in (None, user_id):
continue
room = intervention.room
candidates.append(PlanningCandidate(
source_type="intervention", source_id=intervention.id, title=intervention.title,
description=intervention.description or "", task_type="emergency" if intervention.priority == "urgente" else "curative",
priority=1 if intervention.priority == "urgente" else 3,
constraint="urgent" if intervention.priority == "urgente" else ("fixed" if intervention.scheduled_start and intervention.scheduled_end else "flexible"),
target_date=day, duration_minutes=int(intervention.estimated_duration or 30),
room_id=intervention.room_id, room_name=room.name if room else None,
zone_name=room.zone.name if room and room.zone else None,
building_name=room.building.name if room and room.building else None,
assigned_to=intervention.technician_id or intervention.assigned_to_id,
status=intervention.status, fixed_start=intervention.scheduled_start, fixed_end=intervention.scheduled_end,
))
for admin in AdminTask.query.filter_by(is_active=True).all():
if admin.frequency == "weekly" and admin.day_of_week != day.weekday():
continue
if admin.frequency == "monthly" and admin.day_of_month != day.day:
continue
candidates.append(PlanningCandidate(
source_type="admin_task", source_id=admin.id, title=admin.name,
description=admin.description or "", task_type="admin", priority=3,
constraint="fixed" if admin.start_time else "flexible", target_date=day,
duration_minutes=int(admin.duration_minutes or 30), fixed_start=admin.start_time,
fixed_end=(datetime.combine(day, admin.start_time) + timedelta(minutes=int(admin.duration_minutes or 30))).time() if admin.start_time else None,
status="à planifier",
))
return cls.propose(day, candidates, user_id=user_id)

View file

@ -17,6 +17,32 @@ from datetime import datetime, date, time as dt_time
planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates')
@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)
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),
)
@planning_bp.route('/rooms') @planning_bp.route('/rooms')
@login_required @login_required
def room_schedules(): def room_schedules():

View file

@ -11,6 +11,7 @@
<ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;"> <ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;">
{% if has_permission('intervention.view') %}<li><a class="dropdown-item" href="{{ url_for('interventions.index') }}"><i class="bi bi-list-ul"></i> Interventions</a></li>{% endif %} {% if has_permission('intervention.view') %}<li><a class="dropdown-item" href="{{ url_for('interventions.index') }}"><i class="bi bi-list-ul"></i> Interventions</a></li>{% endif %}
{% if has_permission('planning.view') %} {% if has_permission('planning.view') %}
<li><a class="dropdown-item fw-semibold" href="{{ url_for('planning.my_day') }}"><i class="bi bi-calendar2-week"></i> Ma journée</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.room_schedules') }}"><i class="bi bi-door-open"></i> Planning des salles</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.room_schedules') }}"><i class="bi bi-door-open"></i> Planning des salles</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.index') }}"><i class="bi bi-calendar-check"></i> Planning unifié</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.index') }}"><i class="bi bi-calendar-check"></i> Planning unifié</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.tasks') }}"><i class="bi bi-list-check"></i> Tâches préventives</a></li> <li><a class="dropdown-item" href="{{ url_for('planning.tasks') }}"><i class="bi bi-list-check"></i> Tâches préventives</a></li>

View file

@ -11,6 +11,14 @@
{% block content %} {% block content %}
<h1 class="mb-3 h3"><i class="bi bi-speedometer2"></i> Tableau de bord</h1> <h1 class="mb-3 h3"><i class="bi bi-speedometer2"></i> Tableau de bord</h1>
<p class="text-muted mb-3">Retrouvez ici les interventions, équipements et stocks qui demandent votre attention. Utilisez le menu pour ouvrir une action ou un référentiel.</p> <p class="text-muted mb-3">Retrouvez ici les interventions, équipements et stocks qui demandent votre attention. Utilisez le menu pour ouvrir une action ou un référentiel.</p>
{% if dashboard_permissions.planning %}
<div class="mb-3">
<a class="btn btn-primary" href="{{ url_for('planning.my_day') }}">
<i class="bi bi-calendar2-week me-1"></i>Ma journée
</a>
<span class="text-muted small ms-2">Voir les tâches proposées pour aujourd'hui.</span>
</div>
{% endif %}
{% if dashboard_permissions.ai %}<!-- Alerte modele IA --> {% if dashboard_permissions.ai %}<!-- Alerte modele IA -->
<div id="ai-alert" style="display:none;" class="alert alert-danger mb-3"> <div id="ai-alert" style="display:none;" class="alert alert-danger mb-3">

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Ma journée{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h1 class="h3 mb-1"><i class="bi bi-calendar2-week"></i> Ma journée</h1>
<p class="text-muted mb-0">Une proposition de tâches pour le {{ target_date.strftime('%d/%m/%Y') }}. Vous gardez la main sur les changements.</p>
</div>
<div class="btn-group" role="group" aria-label="Navigation entre les jours">
<a class="btn btn-outline-secondary" href="{{ url_for('planning.my_day', date=previous_date.isoformat()) }}">← Jour précédent</a>
<a class="btn btn-outline-primary" href="{{ url_for('planning.my_day') }}">Aujourd'hui</a>
<a class="btn btn-outline-secondary" href="{{ url_for('planning.my_day', date=next_date.isoformat()) }}">Jour suivant →</a>
</div>
</div>
{% if not hours_configured %}
<div class="alert alert-warning" role="alert">
<strong>Horaires de travail non configurés pour cette journée.</strong>
La proposition automatique reste désactivée. Configurez les horaires du
technicien ou utilisez la planification manuelle existante.
</div>
{% endif %}
{% if candidates %}
<div class="timeline d-grid gap-3" aria-label="Tâches proposées">
{% for item in candidates %}
<article class="card shadow-sm border-start border-{{ 'danger' if item.task_type == 'emergency' else 'primary' }} border-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between gap-2">
<div>
<div class="text-muted small">
{% if item.proposed_start and item.proposed_end %}
{{ item.proposed_start.strftime('%H:%M') }} {{ item.proposed_end.strftime('%H:%M') }}
{% else %}Horaire à définir{% endif %}
· {{ item.duration_minutes }} min
</div>
<h2 class="h5 mb-1 mt-1">{{ item.title }}</h2>
<div class="small text-muted">{{ item.location_label }}</div>
</div>
<div class="text-end">
<span class="badge text-bg-secondary">{{ item.type_label }}</span>
<span class="badge text-bg-light border">{{ item.constraint_label }}</span>
<div class="small mt-1">{{ item.status|capitalize }}</div>
</div>
</div>
{% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="card border-0 bg-light">
<div class="card-body py-5 text-center">
<i class="bi bi-calendar-check fs-1 text-muted"></i>
<h2 class="h5 mt-3">Aucune tâche à afficher</h2>
<p class="text-muted mb-0">Les interventions, maintenances préventives et tâches administratives du jour apparaîtront ici.</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -76,6 +76,7 @@ def test_new_menu_destinations_do_not_return_accidental_404_or_500(authenticated
paths = ( paths = (
"/interventions/", "/interventions/",
"/planning/rooms", "/planning/rooms",
"/planning/my-day",
"/planning/", "/planning/",
"/equipments/", "/equipments/",
"/equipments/buildings/", "/equipments/buildings/",

View file

@ -0,0 +1,49 @@
from datetime import date, time
from app_new.core.services.day_planner import DayPlanner, PlanningCandidate, WorkWindow
def test_proposal_respects_pause_and_is_deterministic(monkeypatch):
monkeypatch.setattr(
DayPlanner,
"work_windows",
staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(12)), WorkWindow(time(13, 30), time(17))]),
)
candidates = [
PlanningCandidate("scheduled_task", 2, "Contrôle B", duration_minutes=45),
PlanningCandidate("scheduled_task", 1, "Contrôle A", duration_minutes=45),
]
first = DayPlanner.propose(date(2026, 8, 24), candidates)
assert [(item.proposed_start, item.proposed_end) for item in first] == [
(time(8), time(8, 45)), (time(8, 45), time(9, 30))
]
assert all(item.proposed_end <= time(12) for item in first)
second = DayPlanner.propose(date(2026, 8, 24), [
PlanningCandidate("scheduled_task", 2, "Contrôle B", duration_minutes=45),
PlanningCandidate("scheduled_task", 1, "Contrôle A", duration_minutes=45),
])
assert [(item.proposed_start, item.proposed_end) for item in first] == [
(item.proposed_start, item.proposed_end) for item in second
]
def test_fixed_event_is_preserved(monkeypatch):
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(17))]))
fixed = PlanningCandidate("company", 1, "Entreprise", constraint="fixed", fixed_start=time(9), fixed_end=time(10))
flexible = PlanningCandidate("admin", 2, "Commande", duration_minutes=30)
result = DayPlanner.propose(date(2026, 8, 24), [flexible, fixed])
assert next(item for item in result if item.source_id == 1).proposed_start == time(9)
assert next(item for item in result if item.source_id == 2).proposed_end <= time(9)
def test_task_too_long_is_marked_to_reschedule(monkeypatch):
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(9))]))
result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche", duration_minutes=90)])
assert result[0].status == "à replanifier"
assert result[0].proposed_start is None
def test_no_work_window_explains_missing_schedule(monkeypatch):
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: []))
result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")])
assert "horaire de travail" in result[0].explanation