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 %} +