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"