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 = 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():
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
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 = (
|
paths = (
|
||||||
"/interventions/",
|
"/interventions/",
|
||||||
"/planning/rooms",
|
"/planning/rooms",
|
||||||
|
"/planning/my-day",
|
||||||
"/planning/",
|
"/planning/",
|
||||||
"/equipments/",
|
"/equipments/",
|
||||||
"/equipments/buildings/",
|
"/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