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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user