feat(planning): add minimal daily rescheduling

This commit is contained in:
root 2026-08-23 11:05:31 +00:00
parent c5b298cd83
commit a760a4df69
5 changed files with 276 additions and 10 deletions

View file

@ -10,7 +10,7 @@ from datetime import date, datetime, time, timedelta
from typing import Iterable, Optional from typing import Iterable, Optional
from .planning_service import PlanningService from .planning_service import PlanningService
from .room_planning import room_is_available from .room_planning import room_is_available, room_occupancy
TYPE_LABELS = { TYPE_LABELS = {
@ -85,6 +85,44 @@ 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 return a_start < b_end and a_end > b_start
def _resolved_room_occupancy_batch(day, room_ids):
"""Charge les occupations de toutes les salles en une requête."""
if not room_ids:
return {}
from ..models.college import RoomSchedule
monday = day - timedelta(days=day.weekday())
rows = RoomSchedule.query.filter(
RoomSchedule.room_id.in_(room_ids),
RoomSchedule.resolution_status == 'active',
RoomSchedule.week_start == monday,
RoomSchedule.day_of_week == day.weekday(),
).all()
grouped = {room_id: [] for room_id in room_ids}
for row in rows:
if row.valid_from and row.valid_from > day:
continue
if row.valid_to and day > row.valid_to:
continue
grouped.setdefault(row.room_id, []).append(row)
resolved = {}
for room_id, room_rows in grouped.items():
pronote = [row for row in room_rows if row.source == 'pronote']
selected = []
for row in room_rows:
if row.source == 'manual' and not row.protected_from_sync:
replaced = any(
_overlap(row.start_time, row.end_time, other.start_time, other.end_time)
and (not row.class_name or not other.class_name or row.class_name == other.class_name)
and (not row.subject or not other.subject or row.subject == other.subject)
for other in pronote
)
if replaced:
continue
selected.append((row.start_time, row.end_time, row))
resolved[room_id] = selected
return resolved
class DayPlanner: class DayPlanner:
"""Construit une proposition sans appeler aucune intégration externe.""" """Construit une proposition sans appeler aucune intégration externe."""
@ -116,14 +154,13 @@ class DayPlanner:
return windows return windows
@staticmethod @staticmethod
def _slot_available(day, candidate, start, end, occupied): def _slot_available(day, candidate, start, end, occupied, room_occupied=None):
if any(_overlap(start, end, item_start, item_end) for item_start, item_end, _ in occupied): if any(_overlap(start, end, item_start, item_end) for item_start, item_end, _ in occupied):
return False return False
if candidate.room_id and not room_is_available( if candidate.room_id and room_occupied is not None:
candidate.room_id, if any(_overlap(start, end, item_start, item_end) for item_start, item_end, _ in room_occupied.get(candidate.room_id, [])):
datetime.combine(day, start), return False
datetime.combine(day, end), elif candidate.room_id and not room_is_available(candidate.room_id, datetime.combine(day, start), datetime.combine(day, end)):
):
return False return False
if candidate.earliest_start and start < candidate.earliest_start: if candidate.earliest_start and start < candidate.earliest_start:
return False return False
@ -143,11 +180,15 @@ class DayPlanner:
fixed = [c for c in candidates if c.fixed_start and c.fixed_end] 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] flexible = [c for c in candidates if c not in fixed]
occupied = [] occupied = []
room_occupied = _resolved_room_occupancy_batch(day, {candidate.room_id for candidate in candidates if candidate.room_id})
for candidate in sorted(fixed, key=lambda item: (_minutes(item.fixed_start), item.source_type, item.source_id)): 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_start = candidate.fixed_start
candidate.proposed_end = candidate.fixed_end candidate.proposed_end = candidate.fixed_end
candidate.status = "planifié" candidate.status = "planifié"
candidate.explanation = "Horaire fixe conservé." candidate.explanation = "Horaire fixe conservé."
if any(_overlap(candidate.fixed_start, candidate.fixed_end, start, end) for start, end, _ in occupied):
candidate.status = "conflit"
candidate.explanation = "Ce rendez-vous fixe chevauche un autre événement fixe ; décision humaine nécessaire."
occupied.append((candidate.fixed_start, candidate.fixed_end, candidate)) occupied.append((candidate.fixed_start, candidate.fixed_end, candidate))
# Les urgences, échéances et priorités passent avant le simple # Les urgences, échéances et priorités passent avant le simple
@ -172,7 +213,7 @@ class DayPlanner:
window_end = _minutes(window.end) window_end = _minutes(window.end)
while cursor + duration <= window_end: while cursor + duration <= window_end:
start, end = _as_time(cursor), _as_time(cursor + duration) start, end = _as_time(cursor), _as_time(cursor + duration)
if cls._slot_available(day, candidate, start, end, occupied): if cls._slot_available(day, candidate, start, end, occupied, room_occupied):
chosen = (start, end) chosen = (start, end)
break break
cursor += 5 cursor += 5
@ -195,6 +236,29 @@ class DayPlanner:
candidate.explanation = "Aucun créneau compatible aujourd'hui." 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)) return sorted(candidates, key=lambda item: (_minutes(item.proposed_start) if item.proposed_start else 9999, item.source_type, item.source_id))
@classmethod
def alternative_slots(cls, day: date, target: PlanningCandidate, candidates: Iterable[PlanningCandidate], user_id=None, limit=3):
"""Retourne quelques créneaux réellement validables pour une tâche."""
windows = cls.work_windows(day, user_id)
duration = max(int(target.duration_minutes or 1), 1)
occupied = []
room_occupied = _resolved_room_occupancy_batch(day, {item.room_id for item in candidates if item.room_id})
for item in candidates:
if item is target or not item.proposed_start or not item.proposed_end:
continue
occupied.append((item.proposed_start, item.proposed_end, item))
slots = []
for window in windows:
cursor = _minutes(window.start)
while cursor + duration <= _minutes(window.end):
start, end = _as_time(cursor), _as_time(cursor + duration)
if cls._slot_available(day, target, start, end, occupied, room_occupied):
slots.append((start, end))
if len(slots) >= limit:
return slots
cursor += 5
return slots
@classmethod @classmethod
def from_database(cls, day: date, user_id=None): def from_database(cls, day: date, user_id=None):
"""Charge les sources accessibles sans créer de nouvelle table.""" """Charge les sources accessibles sans créer de nouvelle table."""
@ -211,10 +275,14 @@ class DayPlanner:
if user_id and task.assigned_to_id not in (None, user_id): if user_id and task.assigned_to_id not in (None, user_id):
continue continue
room = task.room or (task.equipment.effective_room if task.equipment else None) room = task.room or (task.equipment.effective_room if task.equipment else None)
task_type = "external_company" if task.company_id else "preventive"
title = (task.preventive_task.name if task.preventive_task else task.lot_task.tache if task.lot_task else task.room_name_snapshot or "Maintenance préventive")
if task.company:
title = f"Accompagnement entreprise — {task.company.name}"
candidates.append(PlanningCandidate( candidates.append(PlanningCandidate(
source_type="scheduled_task", source_id=task.id, 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"), title=title,
task_type="preventive", priority=2 if task.lot_task_id else 4, task_type=task_type, priority=2 if task.lot_task_id else 4,
constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible", constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible",
target_date=day, duration_minutes=int(task.estimated_duration or 30), 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, room_id=task.room_id or (room.id if room else None), room_name=room.name if room else None,

View file

@ -32,6 +32,13 @@ def my_day():
target_date = date.today() target_date = date.today()
candidates = DayPlanner.from_database(target_date, user_id=current_user.id) candidates = DayPlanner.from_database(target_date, user_id=current_user.id)
windows = DayPlanner.work_windows(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')
}
return render_template( return render_template(
'planning/my_day.html', 'planning/my_day.html',
target_date=target_date, target_date=target_date,
@ -40,9 +47,74 @@ def my_day():
previous_date=target_date - timedelta(days=1), previous_date=target_date - timedelta(days=1),
next_date=target_date + timedelta(days=1), next_date=target_date + timedelta(days=1),
hours_configured=bool(windows), hours_configured=bool(windows),
alternatives=alternatives,
) )
@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 nest 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 nest 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 nest 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()))
@planning_bp.route('/rooms') @planning_bp.route('/rooms')
@login_required @login_required
def room_schedules(): def room_schedules():

View file

@ -23,6 +23,12 @@
{% endif %} {% endif %}
{% if candidates %} {% if candidates %}
<div class="d-flex flex-wrap gap-2 mb-3" aria-label="Résumé de la journée">
<span class="badge text-bg-primary">{{ candidates|length }} tâche(s)</span>
<span class="badge text-bg-warning">{{ candidates|selectattr('status', 'equalto', 'à replanifier')|list|length }} à replanifier</span>
<span class="badge text-bg-danger">{{ candidates|selectattr('task_type', 'equalto', 'emergency')|list|length }} urgence(s)</span>
<span class="badge text-bg-danger">{{ candidates|selectattr('status', 'equalto', 'conflit')|list|length }} conflit(s)</span>
</div>
<div class="timeline d-grid gap-3" aria-label="Tâches proposées"> <div class="timeline d-grid gap-3" aria-label="Tâches proposées">
{% for item in candidates %} {% for item in candidates %}
<article class="card shadow-sm border-start border-{{ 'danger' if item.task_type == 'emergency' else 'primary' }} border-4"> <article class="card shadow-sm border-start border-{{ 'danger' if item.task_type == 'emergency' else 'primary' }} border-4">
@ -45,6 +51,23 @@
</div> </div>
</div> </div>
{% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %} {% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %}
{% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %}
{% if item_alternatives and item.status != 'conflit' %}
<details class="mt-2">
<summary class="small text-primary" style="cursor:pointer">Replanifier cette tâche</summary>
<form method="post" action="{{ url_for('planning.my_day_reschedule') }}" class="row g-2 mt-1 align-items-end">
<input type="hidden" name="source_type" value="{{ item.source_type }}">
<input type="hidden" name="source_id" value="{{ item.source_id }}">
<input type="hidden" name="date" value="{{ target_date.isoformat() }}">
<div class="col-8 col-sm-5"><label class="form-label small" for="slot-{{ item.source_type }}-{{ item.source_id }}">Créneau compatible</label>
<select class="form-select form-select-sm" id="slot-{{ item.source_type }}-{{ item.source_id }}" name="slot" required>
{% for start, end in item_alternatives %}<option value="{{ start.strftime('%H:%M') }}|{{ end.strftime('%H:%M') }}">{{ start.strftime('%H:%M') }} {{ end.strftime('%H:%M') }}</option>{% endfor %}
</select>
</div>
<div class="col-auto"><button class="btn btn-sm btn-outline-primary" type="submit">Enregistrer</button></div>
</form>
</details>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}

View file

@ -95,3 +95,11 @@ def test_new_menu_destinations_do_not_return_accidental_404_or_500(authenticated
for path in paths: for path in paths:
response = authenticated_client.get(path) response = authenticated_client.get(path)
assert response.status_code not in (404, 500), path assert response.status_code not in (404, 500), path
def test_my_day_replanning_rejects_invalid_slot_without_write(authenticated_client):
response = authenticated_client.post(
"/planning/my-day/reschedule",
data={"source_type": "scheduled_task", "source_id": "999999", "date": "2026-08-24", "slot": "bad"},
)
assert response.status_code == 302

View file

@ -47,3 +47,98 @@ def test_no_work_window_explains_missing_schedule(monkeypatch):
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [])) monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: []))
result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")]) result = DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")])
assert "horaire de travail" in result[0].explanation assert "horaire de travail" in result[0].explanation
def _full_day(monkeypatch):
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(lambda day, user_id=None: [WorkWindow(time(8), time(12)), WorkWindow(time(13, 30), time(17))]))
def test_urgent_candidate_is_ordered_before_flexible(monkeypatch):
_full_day(monkeypatch)
rows = DayPlanner.propose(date(2026, 8, 24), [
PlanningCandidate("admin", 1, "Flexible", priority=4, duration_minutes=20),
PlanningCandidate("intervention", 2, "Fuite", task_type="emergency", constraint="urgent", priority=1, duration_minutes=20),
])
assert rows[0].source_id == 2
assert "créneau" in rows[0].explanation or rows[0].status == "proposé"
def test_deadline_candidate_does_not_finish_after_latest_end(monkeypatch):
_full_day(monkeypatch)
candidate = PlanningCandidate("admin", 1, "Commande", constraint="deadline", duration_minutes=60, latest_end=time(10))
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
assert rows[0].proposed_end <= time(10)
def test_deadline_candidate_is_marked_when_no_slot_before_deadline(monkeypatch):
_full_day(monkeypatch)
candidate = PlanningCandidate("admin", 1, "Commande", constraint="deadline", duration_minutes=30, earliest_start=time(11), latest_end=time(11, 15))
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
assert rows[0].status == "à replanifier"
def test_fixed_overlap_is_visible_as_conflict(monkeypatch):
_full_day(monkeypatch)
rows = DayPlanner.propose(date(2026, 8, 24), [
PlanningCandidate("company", 1, "Entreprise", task_type="external_company", constraint="fixed", fixed_start=time(9), fixed_end=time(10)),
PlanningCandidate("intervention", 2, "Urgence", task_type="emergency", constraint="fixed", fixed_start=time(9, 30), fixed_end=time(10, 15)),
])
assert rows[1].status == "conflit"
assert "chevauche" in rows[1].explanation
def test_geographic_labels_are_kept_for_explanation(monkeypatch):
_full_day(monkeypatch)
rows = DayPlanner.propose(date(2026, 8, 24), [
PlanningCandidate("scheduled_task", 1, "Salle", building_name="A", zone_name="RDC", room_name="101"),
PlanningCandidate("scheduled_task", 2, "Zone", building_name="A", zone_name="RDC", room_name="102"),
])
assert rows[0].location_label == "A > RDC > 101"
assert rows[1].location_label == "A > RDC > 102"
def test_unknown_location_is_explicit(monkeypatch):
_full_day(monkeypatch)
candidate = PlanningCandidate("admin", 1, "Dossier")
assert DayPlanner.propose(date(2026, 8, 24), [candidate])[0].location_label == "Localisation non renseignée"
def test_room_occupation_rejects_slot(monkeypatch):
_full_day(monkeypatch)
monkeypatch.setattr("app_new.core.services.day_planner._resolved_room_occupancy_batch", lambda day, room_ids: {12: [(time(8), time(17), object())]})
candidate = PlanningCandidate("scheduled_task", 1, "Salle occupée", room_id=12, duration_minutes=30)
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
assert rows[0].status == "à replanifier"
def test_room_without_id_can_use_work_window(monkeypatch):
_full_day(monkeypatch)
candidate = PlanningCandidate("scheduled_task", 1, "Sans salle", duration_minutes=30)
rows = DayPlanner.propose(date(2026, 8, 24), [candidate])
assert rows[0].status == "proposé"
def test_alternative_slots_exclude_fixed_event(monkeypatch):
_full_day(monkeypatch)
fixed = PlanningCandidate("company", 1, "Fixe", constraint="fixed", fixed_start=time(8), fixed_end=time(9))
target = PlanningCandidate("admin", 2, "À replacer", duration_minutes=30)
proposed = DayPlanner.propose(date(2026, 8, 24), [fixed, target])
slots = DayPlanner.alternative_slots(date(2026, 8, 24), target, proposed)
assert slots and slots[0][0] >= time(9)
def test_alternative_slots_are_limited_to_three(monkeypatch):
_full_day(monkeypatch)
target = PlanningCandidate("admin", 2, "À replacer", duration_minutes=15)
slots = DayPlanner.alternative_slots(date(2026, 8, 24), target, [target], limit=3)
assert len(slots) == 3
def test_explicit_user_parameter_is_forwarded(monkeypatch):
seen = {}
def windows(day, user_id=None):
seen["user_id"] = user_id
return [WorkWindow(time(8), time(9))]
monkeypatch.setattr(DayPlanner, "work_windows", staticmethod(windows))
DayPlanner.propose(date(2026, 8, 24), [PlanningCandidate("admin", 1, "Tâche")], user_id=42)
assert seen["user_id"] == 42