From c0ed99de9dbd8090feccee5eee984c2d93b6f20e Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Thu, 10 Sep 2026 17:18:54 +0200 Subject: [PATCH] =?UTF-8?q?Rattrapage=20hebdomadaire=20:=20arriv=C3=A9es?= =?UTF-8?q?=20et=20retours=20de=20midi=20en=20secours=20des=20d=C3=A9parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pour tenir les heures de la semaine, les préconisations n'ajustaient que les départs du soir : un retard trop gros était déclaré rattrapage impossible dès minuit atteint. Deux leviers s'enchaînent désormais : avancer les arrivées du matin (jamais avant le début de la présence matin, le retour de repos, ni l'heure actuelle), puis les retours de midi (jamais avant la fin de la présence matin, ou la sortie réelle du matin). Les entrées déjà pointées restent intactes ; l'aide des Réglages documente la cascade. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/calcul.py | 93 +++++++++++++++++++++++++++++++------ app/templates/settings.html | 5 +- app/tests/test_calcul.py | 67 ++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 7b23b79..2fe8171 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -583,6 +583,24 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int, return max(0, du) +def _repartir(left: int, rooms: list[int]) -> tuple[list[int], int]: + """Répartit équitablement `left` minutes sur des créneaux de capacités + `rooms` : chacun reçoit au plus sa capacité, à parts égales autant que + possible ; le surplus d'un créneau saturé se reporte sur les autres. + Renvoie (attributions par créneau, minutes restant à placer).""" + adjs = [0] * len(rooms) + while left > 0: + open_idx = [i for i, room in enumerate(rooms) if adjs[i] < room] + if not open_idx: + break + base, rem = divmod(left, len(open_idx)) + for k, i in enumerate(open_idx): + give = min(rooms[i] - adjs[i], base + (1 if k < rem else 0)) + adjs[i] += give + left -= give + return adjs, left + + def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None, contexte: list[dict] | None = None) -> dict: """Compute totals for a list of pointages (one week). @@ -748,12 +766,16 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # Préconisations : répartit l'écart restant (retard à rattraper, ou avance à # écouler) à parts égales sur les heures de départ du soir des demi-journées - # restantes, pour viser un solde à 0 en fin de semaine. Le matin n'est jamais - # ajusté : la pause de midi (matin_fin → reprise, 12:00 → 13:30 par défaut) est - # intouchable, et le nominal matin qui n'y tient pas bascule sur l'après-midi du - # même jour. L'après-midi vise le départ du soir configuré (plafond souple : - # dépassé seulement si le rattrapage ne tient nulle part avant), avec minuit - # comme limite dure en cas de retard, ou aprem_fin comme plancher en cas d'avance. + # restantes, pour viser un solde à 0 en fin de semaine. En cas de retard, si + # les départs du soir ne suffisent pas (saturés à minuit), deux leviers + # supplémentaires entrent en jeu dans l'ordre, bornés par les plages de + # présence des préférences : avancer les arrivées du matin (jamais avant le + # 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). + # Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi. for jour in jours: jour["cible_matin"] = None jour["cible_aprem"] = None @@ -829,6 +851,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int # (avant le départ du soir), puis au-delà si ça ne tient pas. Le surplus d'un créneau # 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 if ecart_total > 0: left = ecart_total for cap_key in ("cap_pref", "cap"): @@ -842,16 +865,57 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int adjs[i] += give left -= give elif ecart_total < 0: - left = -ecart_total - while left > 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"]] 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, len(open_idx)) + base, rem = divmod(left_avance, len(open_idx)) for k, i in enumerate(open_idx): take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0)) adjs[i] -= take - left -= 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 + # les retours de midi (jamais avant la fin de « Présence matin », ni avant + # la sortie réelle du matin s'il est déjà pointé). Une entrée déjà pointée + # n'est pas modifiable ; le jour en cours ne vise pas une heure passée. + arrivee_advs: dict[int, int] = {} # id(slot matin) → minutes d'avance à l'arrivée + reprise_advs: dict[int, int] = {} # id(slot aprem) → minutes d'avance au retour de midi + if left > 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 + floor = max(matin_debut_min, s["jour"].get("retour_min") or 0) + if s["jour"]["date"] == today: + floor = max(floor, now_min) + room = max(0, s["debut"] - floor) + if room: + candidats.append((id(s), room)) + gains, left = _repartir(left, [room for _, room in candidats]) + arrivee_advs = {key: gain for (key, _), gain in zip(candidats, gains)} + if left > 0: + candidats = [] + for s in aprem_slots: + j = s["jour"] + if j.get("aprem_entree"): + continue # reprise déjà pointée : non modifiable + # Plancher : fin de « Présence matin » — la pause de midi (entre les + # deux présences) peut être raccourcie jusqu'à zéro à l'extrême, mais + # le retour n'est jamais proposé avant la fin du matin réel. + floor = matin_fin_min + if j.get("matin_sortie"): + floor = max(floor, hhmm_to_minutes(j["matin_sortie"])) + if j["date"] == today: + floor = max(floor, now_min) + room = max(0, s["debut"] - floor) + if room: + candidats.append((id(s), room)) + gains, left = _repartir(left, [room for _, room in candidats]) + reprise_advs = {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, @@ -863,9 +927,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int and not jour.get(f"{half}_sortie") ) - # Le matin propose toujours son horaire nominal (jamais ajusté par le rattrapage + # Le matin propose son horaire nominal (jamais ajusté par le rattrapage # hebdomadaire) ; l'après-midi propose son horaire nominal éventuellement décalé - # par l'écart (retard ou avance) réparti ci-dessus. + # par l'écart (retard ou avance) réparti ci-dessus. En rattrapage extrême, les + # arrivées du matin et les retours de midi peuvent être avancés (ci-dessus). for s in slots: if s["half"] != "matin": continue @@ -874,7 +939,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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}" + entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0)) s["jour"]["cible_matin_entree"] = entree s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" @@ -882,7 +947,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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}" + entree = minutes_to_time(s["debut"] - reprise_advs.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/settings.html b/app/templates/settings.html index 54c3f98..2619fb2 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -14,7 +14,10 @@ l'après-midi, utilisées pour les avertissements de saisie et les préconisations de fin de demi-journée. Les heures visées (arrivée le matin, départ le soir) guident les préconisations : le matin est planifié depuis ton arrivée visée, et le rattrapage - est casé avant ton départ visé tant que c'est possible. Propres à ton compte — + 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 — ne change rien pour les autres.

diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index 64b3e8d..b6860da 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -438,3 +438,70 @@ def test_compute_week_cible_en_cours_ne_recommande_jamais_un_depart_deja_passe(m result = compute_week(pointages, [], HEURES_JOUR_MIN, plages) jeudi = result["jours"][3] assert jeudi["cible_aprem"] == "17:19" + + +# ── Rattrapage extrême : arrivées et retours de midi ────────────────────────── + +def test_rattrapage_sature_departs_arrivees_puis_retour_de_midi(freeze_today): + """Retard qui dépasse la capacité des départs du soir (minuit) : l'arrivée + du matin est avancée (jamais avant le début de « Présence matin »), puis le + retour de midi (jamais avant la fin de « Présence matin »). Arrivée visée + 10:00, lun-jeu complets courts de 105 min/jour, vendredi congé, lundi vide : + 420 min de retard — 281 dans le départ du soir (→ 23:59), 60 en arrivant à + 09:00 (début Présence matin), 79 en reprenant à 12:11.""" + plages = {"arrivee_visee": "10:00"} + pointages = [ + _pointage("2026-07-13", "09:00", "12:00", "14:00", "17:03"), + _pointage("2026-07-14", "09:00", "12:00", "14:00", "17:03"), + _pointage("2026-07-15", "09:00", "12:00", "14:00", "17:03"), + _pointage("2026-07-16", "09:00", "12:00", "14:00", "17:03"), + _pointage("2026-07-17"), + _pointage("2026-07-20"), + ] + conges = [{"date": "2026-07-17", "type": "jour"}] + result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages) + lundi = result["jours"][5] + assert result["extra_min"] == 420 + assert lundi["cible_matin_entree"] == "09:00" # arrivée avancée jusqu'au début de Présence matin + assert lundi["cible_matin"] == "12:00" + assert lundi["cible_aprem_entree"] == "12:11" # retour de midi avancé de 79 min + assert lundi["cible_aprem"] == "23:59" # départ saturé à minuit + + +def test_rattrapage_par_defaut_n_avance_pas_larrivee_avant_presence_matin(freeze_today): + """Plages par défaut : l'arrivée visée (09:00) est déjà le début de + « Présence matin », rien à avancer — le levier restant est le retour de + midi, raccourci jusqu'à la fin de « Présence matin » (12:00).""" + pointages = [ + _pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-17"), + _pointage("2026-07-20"), + ] + conges = [{"date": "2026-07-17", "type": "jour"}] + result = compute_week(pointages, conges, HEURES_JOUR_MIN) + lundi = result["jours"][5] + assert lundi["cible_matin_entree"] == "09:00" # déjà au début de Présence matin + assert lundi["cible_matin"] == "12:00" + assert lundi["cible_aprem_entree"] == "12:00" # pause réduite jusqu'à la fin de Présence matin + assert lundi["cible_aprem"] == "23:59" + + +def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today): + """Le jour même (gelé à 10:30), ni l'arrivée du matin ni le retour de midi + ne peuvent être proposés avant maintenant : l'arrivée reste à l'heure + visée, le retour de midi est borné par la fin de « Présence matin ».""" + pointages = [ + _pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"), + _pointage("2026-07-17"), # aujourd'hui 10:30, rien pointé + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN) + vendredi = result["jours"][4] + 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"