Préconisations d'horaire : cascade d'avance et correctif de double comptage

Une avance que les départs du soir ne peuvent pas écouler (plancher de
présence) s'écoule désormais en cascade miroir du rattrapage : arrivées
du matin retardées, puis retours de midi, toujours bornés par les plages
de présence.

Une demi-journée en cours qui dépasse son nominal (ex. sortie de midi
oubliée) comptait son temps écoulé deux fois : suivre les cibles
sur-livrait jusqu'à 1h45 par semaine. Le créneau saturé propose
maintenant « sortir maintenant » et l'écart se reporte sur les autres
créneaux ; plus de retard fantôme affiché.

Le README liste désormais l'ensemble des règles de préconisation.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-11 14:04:37 +02:00
parent c0ed99de9d
commit 45b450c1ca
5 changed files with 275 additions and 36 deletions

View File

@ -505,3 +505,169 @@ def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today):
assert vendredi["cible_matin_entree"] == "09:00" # pas d'arrivée rétroactive
assert vendredi["cible_aprem_entree"] == "12:00" # borné par Présence matin, pas avant 10:30
assert vendredi["cible_aprem"] == "23:59"
# ── Avance : arrivées et retours de midi retardés en secours des départs ──────
def test_avance_ecoulee_sur_arrivee_puis_retour_de_midi(freeze_today):
"""Jeudi fini à +48 avec une sortie nominale déjà au plancher aprem_fin
(17:00) : l'avance ne tenait nulle part et les cibles restaient au rythme
nominal. Elle s'écoule désormais en retardant l'arrivée du matin (08:30 →
09:00, début de Présence matin) puis le retour de midi (13:30 → 13:48,
sous le début de Présence après-midi 14:00)."""
plages = {"arrivee_visee": "08:30"}
pointages = [
_pointage("2026-07-13", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-14", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-15", "08:30", "12:00", "13:30", "17:00"),
_pointage("2026-07-16", "08:30", "12:00", "13:30", "17:48"), # +48
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, 420, plages) # 7h00/jour : sortie nominale 17:00
lundi = result["jours"][5]
assert result["avance_min"] == 48
assert lundi["cible_matin_entree"] == "09:00" # arrivée retardée jusqu'à Présence matin
assert lundi["cible_matin"] == "12:00"
assert lundi["cible_aprem_entree"] == "13:48" # retour de midi retardé de 18 min
assert lundi["cible_aprem"] == "17:00" # déjà au plancher, rien à avancer
def test_avance_par_defaut_passe_par_le_retour_de_midi(freeze_today):
"""Plages par défaut (arrivée visée = début de Présence matin, rien à
retarder côté arrivée) : l'avance restante après les départs du soir
s'écoule sur le retour de midi, plafonné au début de Présence après-midi."""
pointages = [
_pointage("2026-07-13", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-14", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-15", "09:00", "12:00", "13:30", "17:30"),
_pointage("2026-07-16", "09:00", "12:00", "13:30", "18:10"), # +40
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, 420)
lundi = result["jours"][5]
assert result["avance_min"] == 40
assert lundi["cible_matin_entree"] == "09:00" # rien à retarder (visée = Présence matin)
assert lundi["cible_aprem_entree"] == "13:40" # retour de midi retardé de 10 min
assert lundi["cible_aprem"] == "17:00" # départ réduit de 30 min puis plafonné
# ── Demi-journée en cours saturée (temps écoulé ≥ nominal) ────────────────────
class _Freezer:
"""Fige calcul.datetime à une date/heure donnée (2026-07-17 = vendredi)."""
@staticmethod
def freeze(monkeypatch, heure, minute):
import calcul as calcul_module
class FrozenDatetime(calcul_module.datetime):
_frozen = calcul_module.datetime(2026, 7, 17, heure, minute)
@classmethod
def today(cls):
return cls._frozen
monkeypatch.setattr(calcul_module, "datetime", FrozenDatetime)
def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
"""Régression : vendredi matin en cours depuis 09:00 sans sortie, il est
13:45 (sortie de midi oubliée). Le temps écoulé (285 min) dépasse la
capacité du créneau matin (180 min) : il était compté deux fois — une fois
dans le travaillé, une fois réintégré au plan — si bien que suivre les
cibles sur-livrait de 105 min. Désormais le créneau saturé propose
« sortie maintenant », ne réintègre rien, et l'après-midi est allégé
d'autant : suivi des cibles → solde 0 (à ~12 min près bornées par les
plages : reprise ≥ maintenant, plancher 17:00)."""
_Freezer.freeze(monkeypatch, 13, 45)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", matin_entree="09:00"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
# Sortie du matin = maintenant (285 min déjà faites, créneau saturé)
assert vendredi["cible_matin"] == "13:45"
# L'après-midi absorbe l'excédent du matin : départ au plancher 17:00
# (nominal 288 − 78 de marge) et reprise retardée à maintenant.
assert vendredi["cible_aprem_entree"] == "13:45"
assert vendredi["cible_aprem"] == "17:00"
# Suivre les cibles ne sur-livre plus de 105 min : reste ≤ 12 min
# (le solde résiduel vient des bornes de plages, pas du calcul).
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", vendredi["cible_matin"],
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert 0 <= apres["solde_min"] <= 12
def test_en_cours_sature_aprem_propose_sortie_immediate(monkeypatch):
"""Régression : vendredi après-midi en cours depuis 13:30 sans sortie, il est
19:30 — le nominal aprem (234) est dépassé de 126 min. L'écart affichait un
retard fantôme de 54 min ; désormais l'avance réelle (72 min) est montrée et
la seule cible cohérente est « sortir maintenant »."""
_Freezer.freeze(monkeypatch, 19, 30)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:00", aprem_entree="13:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 0 # plus de retard fantôme
assert result["avance_min"] == 72 # l'excédent déjà travaillé, affiché tel quel
assert vendredi["cible_aprem"] == "19:30" # sortir maintenant
def test_en_cours_non_sature_reste_inchange(monkeypatch):
"""Un créneau en cours classique (écoulé < nominal) garde le comportement
historique : le temps écoulé est réintégré au plan, le rattrapage du
retard pousse la sortie du soir au-delà du nominal."""
_Freezer.freeze(monkeypatch, 14, 20)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "16:24"), # -90
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:00", aprem_entree="13:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert result["extra_min"] == 144
assert vendredi["cible_aprem"] == "19:48"
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", "12:00", "13:30", vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert apres["solde_min"] == 0
def test_entree_apres_fin_matin_le_surplus_ne_double_compte_pas(monkeypatch):
"""Régression : entrée du matin pointée à 12:30 (après la fin de Présence
matin), sans sortie, il est 13:45. Aucun créneau matin n'existe (capacité
nulle) : le temps écoulé (75 min) ne doit pas être réintégré au plan, sinon
l'après-midi du même jour se retrouve allongé de 75 min en trop. Le surplus
matin (234) bascule sur l'après-midi, réduit de ce qui est déjà fait."""
_Freezer.freeze(monkeypatch, 13, 45)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", matin_entree="12:30"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert vendredi["cible_matin"] == "13:45" # sortie immédiate de l'entrée ouverte
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "12:30", vendredi["cible_matin"],
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert apres["solde_min"] == 0