diff --git a/app/calcul.py b/app/calcul.py index 7777666..c0d2482 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,5 +1,5 @@ """Business logic: compute worked hours and expected hours per day/week.""" -from datetime import datetime, timedelta +from datetime import datetime DEFAULT_PLAGES = { "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin @@ -117,14 +117,30 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int 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 (au lieu de tout reporter sur la fin de journée courante), pour permettre - # un rattrapage progressif et confortable jusqu'à la fin de semaine. + # restantes, dans la limite de la capacité de chaque créneau (le matin est plafonné + # à la reprise de l'après-midi, l'après-midi à minuit). for jour in jours: jour["cible_matin"] = None jour["cible_aprem"] = None restant = total_du - total_travaille - slots = [] # (jour, "matin"|"aprem", nominal_min) + + # 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 + if jour.get("matin_entree") and not jour.get("matin_sortie"): + restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"])) + 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 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 @@ -138,33 +154,38 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg if not matin_done: - slots.append((jour, "matin", matin_nominal)) + debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min + slots.append({"jour": jour, "half": "matin", "nominal": matin_nominal, + "debut": debut, "cap": max(0, pause_dejeuner_fin_min - debut)}) if not aprem_done: - slots.append((jour, "aprem", aprem_nominal)) + debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, + "debut": debut, "cap": max(0, 23 * 60 + 59 - debut)}) - extra_total = max(0, restant - sum(s[2] for s in slots)) if slots else 0 + extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0 slots_restants = len(slots) - if slots_restants: - base, remainder = divmod(extra_total, slots_restants) - for i, (jour, half, nominal) in enumerate(slots): - adj = base + (1 if i < remainder else 0) - duree_cible = nominal + adj - if adj <= 0: - continue # rythme normal suffit pour cette demi-journée, rien à signaler - if half == "matin": - debut_min = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min - sortie_min = max(debut_min + duree_cible, matin_fin_min) - else: - debut_min = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min - sortie_min = max(debut_min + duree_cible, aprem_fin_min) - if sortie_min > 23 * 60 + 59: - continue # rattrapage impossible en une seule demi-journée - cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" - if half == "matin": - jour["cible_matin"] = cible - else: - jour["cible_aprem"] = cible + # Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau : + # le surplus d'un créneau saturé se reporte sur les autres. + adjs = [0] * slots_restants + left = extra_total + while left > 0: + open_idx = [i for i, s in enumerate(slots) if adjs[i] < s["cap"] - s["nominal"]] + if not open_idx: + break # tous les créneaux saturés : rattrapage partiellement impossible cette semaine + base, rem = divmod(left, len(open_idx)) + for k, i in enumerate(open_idx): + give = min(slots[i]["cap"] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0)) + adjs[i] += give + left -= give + + for i, s in enumerate(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 reste_min = max(0, restant) diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index 5d26d21..20ec771 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -29,5 +29,10 @@ Vous êtes à jour ou en avance — rien à rattraper cette semaine. {% endif %} + {% elif result.solde_min < 0 %} +
+ Semaine terminée avec {{ result.solde }} de manque — + complétez ou corrigez les saisies, ou rattrapez sur une autre semaine. +
{% endif %} diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index 6a231b7..3ef5f83 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -174,6 +174,75 @@ def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(freeze_today) assert vendredi["cible_aprem"] is None +def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today): + """Régression : une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) + était comptée deux fois — son temps écoulé dans le travaillé ET son nominal complet + comme capacité restante — ce qui masquait le rattrapage pendant la journée. + + Lundi en retard de 60 min, mardi-jeudi complets, vendredi (aujourd'hui, gelé à + 10:30) matin en cours depuis 09:00. Les 60 min doivent être réparties : le matin + est plafonné par la pause déjeuner (max +36), donc 30/30 passe sans saturation. + """ + pointages = [ + _pointage("2026-07-13", "09:00", "12:24", "13:30", "16:54"), # -60 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", matin_entree="09:00"), # en cours, 90 min écoulées + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN) + vendredi = result["jours"][4] + assert result["extra_min"] == 60 + # matin : 09:00 + 234 nominal + 30 = 13:24 ; aprem : 13:30 + 234 + 30 = 17:54 + assert vendredi["cible_matin"] == "13:24" + assert vendredi["cible_aprem"] == "17:54" + + +def test_compute_week_cible_matin_plafonnee_a_la_pause_dejeuner(freeze_today): + """La cible matin ne dépasse jamais la reprise de l'après-midi : un créneau + saturé reçoit sa capacité max, le surplus reste non alloué (pas de cible absurde). + + Vendredi en congé après-midi : seule sa matinée reste (nominal 234, capacité + 09:00→13:30 = 270, soit +36 max). Avec 120 min de retard, la cible est plafonnée + à 13:30 au lieu d'un impossible 14:54. + """ + conges = [{"date": "2026-07-17", "type": "aprem"}] + pointages = [ + _pointage("2026-07-13", "09:00", "12:24", "13:30", "15:54"), # -120 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, conges, HEURES_JOUR_MIN) + vendredi = result["jours"][4] + assert result["extra_min"] == 120 + assert vendredi["cible_matin"] == "13:30" + assert vendredi["cible_aprem"] is None # congé + + +def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today): + """Quand un créneau matin sature (plafond pause déjeuner), le surplus doit se + reporter sur les créneaux encore ouverts au lieu d'être perdu. + + 80 min de retard sur 2 demi-journées de vendredi : réparti 40/40, mais le matin + plafonne à +36 — les 4 min restantes basculent sur l'après-midi (+44). + """ + pointages = [ + _pointage("2026-07-13", "09:00", "12:24", "13:30", "16:34"), # -80 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 result["extra_min"] == 80 + # matin saturé : 09:00 + 270 = 13:30 ; aprem : 13:30 + 234 + 44 = 18:08 + assert vendredi["cible_matin"] == "13:30" + assert vendredi["cible_aprem"] == "18:08" + + def test_compute_week_plages_personnalisees(freeze_today): """Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts.""" plages = {