From 45b450c1cad4fec7d2ff61029a67fa1766c9f568 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Fri, 11 Sep 2026 14:04:37 +0200 Subject: [PATCH] =?UTF-8?q?Pr=C3=A9conisations=20d'horaire=20:=20cascade?= =?UTF-8?q?=20d'avance=20et=20correctif=20de=20double=20comptage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 36 +++++--- app/calcul.py | 102 ++++++++++++++++------ app/templates/_soldes.html | 2 +- app/templates/settings.html | 5 +- app/tests/test_calcul.py | 166 ++++++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 63ddad9..4829483 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,33 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d Ces horaires sont réglables par utilisateur depuis la page **Réglages** de l'app (voir [Configuration](#configuration)). -Le matin n'est **jamais** touché : sa durée nominale est fixe, et si elle ne rentre pas -avant la pause déjeuner, le surplus est reporté sur l'après-midi du même jour. Quand un -écart hebdomadaire apparaît, il est **réparti uniquement sur les heures de départ du -soir** des demi-journées restantes (répartition égale, recalculée à chaque fois qu'un -départ atteint sa limite) : -- un **retard** repousse les départs — d'abord dans la marge de confort `depart_vise`, - puis au-delà si besoin (jusqu'à minuit) ; -- une **avance** avance les départs, mais jamais en dessous du minimum de présence de - l'après-midi. +Le moteur de préconisations vise un **solde à 0 en fin de semaine** : l'écart entre +heures dues et heures déjà travaillées (temps écoulé des demi-journées en cours déduit) +est réparti à parts égales sur les demi-journées restantes. Les règles, dans l'ordre : -Si le rythme normal suffit déjà à rentrer dans les clous, aucune préconisation n'est affichée. +1. **Créneaux** : chaque demi-journée non pointée (entrée + sortie) et non congéfiée + reste à planifier, y compris les jours passés — la saisie après coup est admise. +2. **Le matin n'est jamais ajusté** par l'écart hebdomadaire : arrivée à l'heure visée + (`arrivee_visee`, au plus tôt l'heure de retour après repos d'astreinte), sortie au + nominal plafonné à la pause de midi. Le nominal matin qui ne tient pas avant la + pause **bascule sur l'après-midi du même jour** (s'il est encore ouvert). +3. **Retard** — trois leviers en cascade : repousser les **départs du soir** (d'abord + dans la marge du départ visé `depart_vise`, puis jusqu'à minuit) ; sinon avancer + les **arrivées du matin** (jamais avant le début de présence matin) ; sinon avancer + les **retours de midi** (jamais avant la fin de présence matin, ni avant la sortie + du matin pointée, ni avant l'heure qu'il est). +4. **Avance** — cascade miroir : avancer les **départs du soir** (jamais sous la fin de + présence après-midi) ; sinon retarder les **arrivées du matin** (jamais après le + début de présence matin) ; sinon retarder les **retours de midi** (jamais après le + début de présence après-midi — la pause s'allonge d'autant). +5. **Journée en cours** : une demi-journée commencée (entrée pointée sans sortie) se + planifie depuis son heure d'entrée, son temps déjà écoulé est réintégré au plan ; + s'il atteint ou dépasse le nominal du créneau (**créneau saturé**, ex. sortie de + midi oubliée), la cible devient « sortir maintenant » et l'écart se reporte sur les + autres créneaux — jamais de double comptage du temps déjà fait. +6. Une entrée déjà pointée n'est jamais déplacée, et aucune cible du jour courant ne + recommande une heure déjà passée. Ce qui ne tient dans aucune plage reste visible + dans les compteurs « reste à faire » / avance. --- diff --git a/app/calcul.py b/app/calcul.py index 2fe8171..a00201d 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -613,6 +613,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0 matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) + aprem_debut_min = hhmm_to_minutes(plages["aprem_debut"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"]) arrivee_visee_min = hhmm_to_minutes(plages["arrivee_visee"]) if plages.get("arrivee_visee") else matin_debut_min @@ -773,8 +774,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # début de « Présence matin », l'heure de retour après repos, ni avant # maintenant pour le jour en cours), puis avancer les retours de midi (la # pause peut être raccourcie, jamais avant la fin de « Présence matin »). - # En cas d'avance, on avance les départs symétriquement, sans jamais - # descendre sous le plancher aprem_fin (présence minimale obligatoire). + # En cas d'avance, cascade miroir : départs du soir avancés (jamais sous la + # fin de « Présence après-midi »), puis arrivées du matin retardées (jamais + # après le début de « Présence matin »), puis retours de midi retardés + # (jamais après le début de « Présence après-midi » : la pause s'allonge). # Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi. for jour in jours: jour["cible_matin"] = None @@ -784,23 +787,19 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int restant = total_du - total_travaille - # Une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) est comptée - # ci-dessous comme un créneau entier à faire depuis son heure d'entrée ; son temps - # déjà écoulé, inclus dans total_travaille, doit donc être réintégré au besoin, - # sinon la même période compte à la fois comme travaillée et comme à venir. today = datetime.today().strftime("%Y-%m-%d") now_min = datetime.today().hour * 60 + datetime.today().minute - restant_planif = restant - for jour in jours: - if jour["date"] != today: - continue - retour = jour.get("retour_min") or 0 - if jour.get("matin_entree") and not jour.get("matin_sortie"): - restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour)) - if jour.get("aprem_entree") and not jour.get("aprem_sortie"): - restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour)) slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage + # restant_planif : reste à planifier. Une demi-journée en cours (entrée pointée + # sans sortie, aujourd'hui) est comptée ci-dessous comme un créneau entier à + # faire depuis son heure d'entrée ; son temps déjà écoulé, inclus dans + # total_travaille, doit donc être réintégré, sinon la même période compte à la + # fois comme travaillée et comme à venir. Sauf si le créneau est saturé (temps + # écoulé ≥ nominal) : la sortie cible retombe alors à « maintenant », la période + # n'est pas re-planifiée, et son excédent déjà travaillé est de facto compté — + # le réintégrer ferait doubler le compte. + restant_planif = restant for jour in jours: # Un jour passé non renseigné n'est pas exclu : cette appli permet de saisir # ses heures après coup, donc une demi-journée vide reste "à faire" quelle @@ -813,6 +812,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # (retour au-delà de minuit suivant) : rien à planifier ce jour-là. if (jour.get("retour_min") or 0) >= 24 * 60: continue + # Temps déjà écoulé des demi-journées en cours aujourd'hui (entrée pointée + # sans sortie) — cf. restant_planif ci-dessus. + ecoule = {} + if jour["date"] == today: + retour = jour.get("retour_min") or 0 + for half in ("matin", "aprem"): + if jour.get(f"{half}_entree") and not jour.get(f"{half}_sortie"): + ecoule[half] = max(0, now_min - max(hhmm_to_minutes(jour[f"{half}_entree"]), retour)) matin_nominal = jour["matin_nominal_min"] aprem_nominal = jour["aprem_nominal_min"] matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg @@ -829,8 +836,15 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int aprem_nominal += surplus # Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa # capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus. - if cap > 0 and matin_nominal > 0: - slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) + # Une demi-journée en cours garde toujours son créneau : l'entrée ouverte + # doit recevoir une heure de sortie, ne serait-ce que « maintenant ». + if (cap > 0 and matin_nominal > 0) or "matin" in ecoule: + nominal = min(matin_nominal, cap) + sature = "matin" in ecoule and ecoule["matin"] >= nominal + if "matin" in ecoule and not sature: + restant_planif += ecoule["matin"] + slots.append({"jour": jour, "half": "matin", "nominal": nominal, "debut": debut, + "sature": sature}) if not aprem_done: debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0) cap = max(0, 23 * 60 + 59 - debut) @@ -838,11 +852,17 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # floor_room = marge disponible pour avancer le départ en cas d'avance, # sans descendre sous le plancher aprem_fin (présence minimale obligatoire). floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min) + sature = "aprem" in ecoule and ecoule["aprem"] >= aprem_nominal + if "aprem" in ecoule and not sature: + restant_planif += ecoule["aprem"] slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut, - "cap": cap, "cap_pref": cap_pref, "floor_room": floor_room}) + "cap": cap, "cap_pref": cap_pref, "floor_room": floor_room, + "sature": sature}) slots_restants = len(slots) - ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0 + # Les créneaux saturés sortent du plan : leur sortie cible est « maintenant », + # ils ne peuvent rien rattraper ni écouler. + ecart_total = (restant_planif - sum(s["nominal"] for s in slots if not s.get("sature"))) if slots else 0 aprem_slots = [s for s in slots if s["half"] == "aprem"] adjs = [0] * len(aprem_slots) @@ -852,11 +872,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # saturé se reporte sur les autres. En cas d'avance, on avance les départs symétriquement, # sans jamais descendre sous le plancher aprem_fin (une seule passe, pas de plafond souple). left = 0 + left_avance = 0 if ecart_total > 0: left = ecart_total for cap_key in ("cap_pref", "cap"): while left > 0: - open_idx = [i for i, s in enumerate(aprem_slots) if adjs[i] < s[cap_key] - s["nominal"]] + open_idx = [i for i, s in enumerate(aprem_slots) + if not s.get("sature") and adjs[i] < s[cap_key] - s["nominal"]] if not open_idx: break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible base, rem = divmod(left, len(open_idx)) @@ -867,7 +889,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int elif ecart_total < 0: left_avance = -ecart_total while left_avance > 0: - open_idx = [i for i, s in enumerate(aprem_slots) if -adjs[i] < s["floor_room"]] + open_idx = [i for i, s in enumerate(aprem_slots) + if not s.get("sature") and -adjs[i] < s["floor_room"]] if not open_idx: break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable base, rem = divmod(left_avance, len(open_idx)) @@ -875,7 +898,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0)) adjs[i] -= take left_avance -= take - # Si les départs du soir ne suffisent pas à absorber le retard, deux leviers # supplémentaires dans l'ordre, bornés par les plages de présence : avancer # les arrivées du matin (jamais avant le début de « Présence matin »), puis @@ -917,6 +939,38 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int gains, left = _repartir(left, [room for _, room in candidats]) reprise_advs = {key: gain for (key, _), gain in zip(candidats, gains)} + # Miroir pour l'avance : si les départs du soir ne peuvent pas tout écouler + # (plancher aprem_fin), retarder d'abord les arrivées du matin (jamais après + # le début de « Présence matin »), puis les retours de midi (jamais après + # le début de « Présence après-midi ») — la pause de midi s'allonge + # d'autant. Une entrée déjà pointée n'est pas modifiable ; le jour en cours + # ne vise pas une heure déjà passée. + arrivee_delays: dict[int, int] = {} # id(slot matin) → minutes de retard à l'arrivée + reprise_delays: dict[int, int] = {} # id(slot aprem) → minutes de retard au retour de midi + if left_avance > 0: + candidats = [] + for s in slots: + if s["half"] != "matin" or s["jour"].get("matin_entree"): + continue # arrivée déjà pointée : non modifiable + base = max(s["debut"], now_min) if s["jour"]["date"] == today else s["debut"] + room = max(0, matin_debut_min - base) + if room: + candidats.append((id(s), room)) + gains, left_avance = _repartir(left_avance, [room for _, room in candidats]) + arrivee_delays = {key: gain for (key, _), gain in zip(candidats, gains)} + if left_avance > 0: + candidats = [] + for s in aprem_slots: + j = s["jour"] + if j.get("aprem_entree"): + continue # reprise déjà pointée : non modifiable + base = max(s["debut"], now_min) if j["date"] == today else s["debut"] + room = max(0, aprem_debut_min - base) + if room: + candidats.append((id(s), room)) + gains, left_avance = _repartir(left_avance, [room for _, room in candidats]) + reprise_delays = {key: gain for (key, _), gain in zip(candidats, gains)} + def _en_cours(jour, half: str) -> bool: """True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée, pas encore de sortie) : la cible ne peut alors pas retomber avant l'heure @@ -939,7 +993,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int sortie_min = s["debut"] + s["nominal"] if _en_cours(s["jour"], "matin"): sortie_min = max(sortie_min, now_min) - entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0)) + entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0) + arrivee_delays.get(id(s), 0)) s["jour"]["cible_matin_entree"] = entree s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" @@ -947,7 +1001,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min) if _en_cours(s["jour"], "aprem"): sortie_min = max(sortie_min, now_min) - entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0)) + entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0) + reprise_delays.get(id(s), 0)) s["jour"]["cible_aprem_entree"] = entree s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index d31297c..bff0d04 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -61,7 +61,7 @@ {% elif result.avance_min > 0 %} Reste à faire : {{ result.reste }} sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }} - — avance à écouler : {{ result.avance }}, répartie sur les départs du soir pour finir la semaine à 0. + — avance à écouler : {{ result.avance }}, répartie sur les départs du soir, puis arrivées et retours de midi, pour finir la semaine à 0. {% elif result.reste_min > 0 %} Reste à faire : {{ result.reste }} sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }} diff --git a/app/templates/settings.html b/app/templates/settings.html index 2619fb2..c75371e 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -17,7 +17,10 @@ est casé avant ton départ visé tant que c'est possible. En rattrapage extrême : départs du soir allongés en premier (jusqu'à minuit), puis arrivée du matin avancée (jamais avant le début de la présence matin), puis retour de midi avancé (jamais - avant la fin de la présence matin). Propres à ton compte — + avant la fin de la présence matin). En avance : départs du soir raccourcis + (jamais sous la fin de la présence après-midi), puis arrivée du matin retardée + (jamais après le début de la présence matin), puis retour de midi retardé + (jamais après le début de la présence après-midi). Propres à ton compte — ne change rien pour les autres.

diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index b6860da..d7fdfb6 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -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