feat(planning): add deterministic daily proposal
This commit is contained in:
parent
3c4ef00d21
commit
de13fd1b22
7 changed files with 407 additions and 0 deletions
260
app_new/core/services/day_planner.py
Normal file
260
app_new/core/services/day_planner.py
Normal 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)
|
||||
|
|
@ -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.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')
|
||||
@login_required
|
||||
def room_schedules():
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<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('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.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>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@
|
|||
{% block content %}
|
||||
<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>
|
||||
{% 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 -->
|
||||
<div id="ai-alert" style="display:none;" class="alert alert-danger mb-3">
|
||||
|
|
|
|||
62
app_new/templates/planning/my_day.html
Normal file
62
app_new/templates/planning/my_day.html
Normal 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 %}
|
||||
|
|
@ -76,6 +76,7 @@ def test_new_menu_destinations_do_not_return_accidental_404_or_500(authenticated
|
|||
paths = (
|
||||
"/interventions/",
|
||||
"/planning/rooms",
|
||||
"/planning/my-day",
|
||||
"/planning/",
|
||||
"/equipments/",
|
||||
"/equipments/buildings/",
|
||||
|
|
|
|||
49
tests/unit/test_day_planner.py
Normal file
49
tests/unit/test_day_planner.py
Normal 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
|
||||
Loading…
Reference in a new issue