From b9a75fffeace6d06e56283d4d5f47a1ab88d8a42 Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 6 Aug 2026 17:12:33 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A9partit=20avance/retard=20sur=20les=20d?= =?UTF-8?q?=C3=A9parts=20du=20soir,=20plus=20jamais=20sur=20le=20matin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rattrapage hebdomadaire ne portait que sur le retard et pouvait s'étaler sur le matin quand il avait de la marge. Objectif demandé : pouvoir planifier les horaires à l'avance pour arriver à 0 en fin de semaine, en ajustant uniquement les heures de départ du soir - dans un sens (retard) comme dans l'autre (avance, plafonnée par aprem_fin). Co-Authored-By: Claude Sonnet 5 --- app/calcul.py | 82 ++++++++++++++++++++++++-------------- app/templates/_soldes.html | 4 ++ app/tests/test_calcul.py | 75 +++++++++++++++++++++++++++------- 3 files changed, 117 insertions(+), 44 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 66d2476..81ad0a6 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -120,14 +120,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int cumul_chain_ok = False jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok - # Préconisations : répartit le manque restant à parts égales sur les demi-journées - # restantes, dans la limite de la capacité de chaque créneau. La pause de midi - # (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible - # matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas - # bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée - # (sinon matin_debut) ; 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. + # 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. for jour in jours: jour["cible_matin"] = None jour["cible_aprem"] = None @@ -149,7 +149,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int if jour.get("aprem_entree") and not jour.get("aprem_sortie"): restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"])) - slots = [] # {jour, half, nominal, debut, cap} — cap = durée maximale du créneau + slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage 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 @@ -171,42 +171,62 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int surplus = max(0, matin_nominal - cap) if surplus and not aprem_done: aprem_nominal += surplus - slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), - "debut": debut, "cap": cap, "cap_pref": cap}) + # 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. + slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) if not aprem_done: debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min cap = max(0, 23 * 60 + 59 - debut) cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap - slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, - "debut": debut, "cap": cap, "cap_pref": cap_pref}) + # 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) + slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut, + "cap": cap, "cap_pref": cap_pref, "floor_room": floor_room}) - extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0 slots_restants = len(slots) + ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0 + aprem_slots = [s for s in slots if s["half"] == "aprem"] + adjs = [0] * len(aprem_slots) - # Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau, - # en deux passes : d'abord dans le cadre visé (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. - adjs = [0] * slots_restants - left = extra_total - for cap_key in ("cap_pref", "cap"): + # Répartition équitable de l'écart (retard ou avance), plafonnée par la capacité de + # chaque créneau du soir, en deux passes en cas de retard : d'abord dans le cadre visé + # (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). + 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"]] + 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)) + for k, i in enumerate(open_idx): + give = min(aprem_slots[i][cap_key] - aprem_slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0)) + adjs[i] += give + left -= give + elif ecart_total < 0: + left = -ecart_total while left > 0: - open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]] + 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 saturés à ce plafond : passe suivante, ou rattrapage impossible + break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable base, rem = divmod(left, len(open_idx)) for k, i in enumerate(open_idx): - give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0)) - adjs[i] += give - left -= give + take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0)) + adjs[i] -= take + left -= take - for i, s in enumerate(slots): - if adjs[i] <= 0: + for i, s in enumerate(aprem_slots): + if adjs[i] == 0: continue # rythme normal suffit pour cette demi-journée, rien à signaler sortie_min = s["debut"] + s["nominal"] + adjs[i] - sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min) - cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" - s["jour"]["cible_matin" if s["half"] == "matin" else "cible_aprem"] = cible + sortie_min = max(sortie_min, aprem_fin_min) + s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" + extra_total = max(0, ecart_total) + avance_total = max(0, -ecart_total) reste_min = max(0, restant) return { @@ -219,6 +239,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "reste": minutes_to_hhmm(reste_min), "extra_min": extra_total, "extra": minutes_to_hhmm(extra_total), + "avance_min": avance_total, + "avance": minutes_to_hhmm(avance_total), "slots_restants": slots_restants, "moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0, "moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0), diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index 20ec771..c01b993 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -21,6 +21,10 @@ 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 '' }} — à rattraper : +{{ result.extra }} (~+{{ result.moyenne_extra }} chacune) + {% 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. {% 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/tests/test_calcul.py b/app/tests/test_calcul.py index 726dab9..cba79db 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -248,14 +248,63 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today assert vendredi["cible_aprem"] == "19:38" -def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(freeze_today): - """Avec des plages personnalisées où la matinée obligatoire est plus longue que - le nominal, une cible matin peut être proposée — mais jamais au-delà de - matin_fin, pour que la pause (matin_fin → reprise) reste libre. +def test_compute_week_avance_reduit_les_departs_du_soir_restants(freeze_today): + """Symétrique du rattrapage : une avance (travaillé plus que dû) doit réduire + les heures de départ du soir des jours restants, pas seulement rester + invisible — pour permettre de planifier un solde à 0 en fin de semaine. + + Plages personnalisées sans report matin→aprem (capacité matin 240 = nominal). + Lundi +20 min d'avance, reste de la semaine vide : les 4 départs restants + sont chacun avancés de 5 min (18:00 nominal → 17:55). + """ + plages = { + "matin_debut": "09:00", "matin_fin": "13:00", + "aprem_debut": "14:00", "aprem_fin": "17:00", + "pause_dejeuner_fin": "14:00", + } + heures_jour = 480 # 240 matin + 240 aprem + pointages = [ + _pointage("2026-07-13", "09:00", "13:00", "14:00", "18:20"), # +20 min + _pointage("2026-07-14"), + _pointage("2026-07-15"), + _pointage("2026-07-16"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], heures_jour, plages) + assert result["extra_min"] == 0 # extra_min ne compte que le retard, pas l'avance + for jour in result["jours"][1:]: + assert jour["cible_matin"] is None + assert jour["cible_aprem"] == "17:55" + + +def test_compute_week_avance_ne_descend_jamais_sous_aprem_fin(freeze_today): + """L'avance ne peut pas faire descendre un départ sous le plancher aprem_fin + (présence minimale obligatoire) : au-delà de la marge disponible, l'avance + restante n'est simplement pas placée (elle reste visible dans le solde). + + 234 min nominal aprem (13:30 + 234 = 17:24), plancher 17:00 : seulement 24 min + de marge par jour. Avec 200 min d'avance sur un seul jour ouvert, la cible + plafonne à 17:00 au lieu de descendre à 17:24 - 200. + """ + pointages = [ + _pointage("2026-07-13", "09:00", "12:24", "13:30", "20:34"), # +200 min + _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"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN) + vendredi = result["jours"][4] + assert vendredi["cible_aprem"] == "17:00" + + +def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_today): + """Le rattrapage hebdomadaire ne porte jamais sur le matin, même quand la + matinée obligatoire est plus longue que le nominal et aurait de la marge. Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi, - 120 min de retard : le matin reçoit +30 max (plafond 13:00), le reste n'est - pas plaçable. La pause 13:00→14:00 reste intacte. + 120 min de retard : le seul créneau ouvert (le matin) n'est pas ajustable, le + retard n'est pas plaçable ailleurs (l'après-midi est en congé). """ plages = { "matin_debut": "08:00", "matin_fin": "13:00", @@ -274,15 +323,14 @@ def test_compute_week_cible_matin_possible_quand_la_plage_matin_a_de_la_marge(fr result = compute_week(pointages, conges, heures_jour, plages) vendredi = result["jours"][4] assert result["extra_min"] == 120 - # 08:00 + 270 nominal + 30 (saturé au plafond matin_fin) = 13:00 - assert vendredi["cible_matin"] == "13:00" + assert vendredi["cible_matin"] is None assert vendredi["cible_aprem"] is None # congé -def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today): - """L'arrivée visée sert de début projeté du matin : en visant 08:00, le créneau - matin gagne de la capacité (08:00→12:00 = 240 ≥ 234 nominal) et peut recevoir - du rattrapage — toujours sans dépasser matin_fin. +def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_today): + """L'arrivée visée sert de début projeté du matin (pour le calcul du surplus + basculant vers l'après-midi), mais n'ouvre jamais le matin au rattrapage + hebdomadaire — celui-ci reste réservé aux départs du soir. """ plages = {"arrivee_visee": "08:00"} conges = [{"date": "2026-07-17", "type": "aprem"}] @@ -296,8 +344,7 @@ def test_compute_week_arrivee_visee_etend_le_creneau_matin(freeze_today): result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages) vendredi = result["jours"][4] assert result["extra_min"] == 120 - # 08:00 + 234 nominal + 6 (saturé au plafond matin_fin) = 12:00 - assert vendredi["cible_matin"] == "12:00" + assert vendredi["cible_matin"] is None def test_compute_week_depart_vise_plafond_souple(freeze_today):