Corrige le double comptage des demi-journées en cours et plafonne les cibles matin

Trois corrections sur les préconisations :
- Une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) était
  comptée deux fois — temps écoulé dans le travaillé ET nominal complet comme
  capacité restante — ce qui faisait disparaître le rattrapage pendant la
  journée et produisait des cibles en manque du temps déjà écoulé. Le temps
  écoulé est réintégré au besoin planifié.
- La cible matin est plafonnée à la reprise de l'après-midi ; un créneau
  saturé reporte son surplus sur les créneaux encore ouverts (waterfill)
  au lieu d'afficher une sortie matin absurde.
- Une semaine entièrement pointée mais en déficit affiche désormais un
  bandeau "Semaine terminée avec -X de manque" au lieu de rien.
This commit is contained in:
Antoine
2026-07-17 23:33:01 +02:00
parent 436f923148
commit 12666e73f4
3 changed files with 122 additions and 27 deletions

View File

@ -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)