Répartit les préconisations de rattrapage sur les demi-journées restantes de la semaine

Au lieu de reporter tout le manque horaire sur la sortie du jour courant, le calcul
distribue désormais le retard à parts égales sur chaque demi-journée non traitée
(aujourd'hui et jours à venir), avec un plafond de sécurité si le rattrapage dépasse
minuit. La table affiche une cible par demi-journée (matin/après-midi) et le bandeau
de soldes indique le reste à faire et le nombre de demi-journées restantes.
This commit is contained in:
Antoine
2026-07-17 23:02:07 +02:00
parent c2d7d0e3ac
commit e6b5173018
7 changed files with 138 additions and 99 deletions

View File

@ -75,7 +75,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
matin_window = matin_fin_min - matin_debut_min
total_travaille = 0
total_du = 0
@ -117,64 +116,57 @@ 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
# Sortie cible: optimal aprem exit so the week totals exactly the target hours
# 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.
for jour in jours:
jour["cible_matin"] = None
jour["cible_aprem"] = None
today = datetime.today().strftime("%Y-%m-%d")
now_min = datetime.today().hour * 60 + datetime.today().minute
worked_before = 0
for i, jour in enumerate(jours):
restant = total_du - total_travaille
slots = [] # (jour, "matin"|"aprem", nominal_min)
for jour in jours:
if jour["date"] < today:
continue # jour passé : impossible d'y rattraper quoi que ce soit
cg = jour["conge"]
# Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis
is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree")
if is_future_untouched:
jour["sortie_cible"] = None
jour["entree_cible"] = None
worked_before += jour["travaille_min"]
continue
if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg:
jour["sortie_cible"] = None
jour["entree_cible"] = None
if "aprem" in cg and "matin" not in cg and jour["du_min"] > 0:
du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:])
if du_remaining_mat == 0 and not jour.get("matin_entree"):
needed_today_mat = max(0, total_du - worked_before - du_remaining_mat)
if needed_today_mat > matin_window: # plus que le créneau matin obligatoire
entree_min = matin_fin_min - needed_today_mat
floor_min = matin_debut_min - matin_window # pas avant ce plancher symétrique
if entree_min >= floor_min:
jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
worked_before += jour["travaille_min"]
du = jour["du_min"]
if du == 0:
continue
matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2)
aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2)
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))
if not aprem_done:
slots.append((jour, "aprem", aprem_nominal))
du_remaining = sum(j["du_min"] for j in jours[i + 1:])
needed_today = max(0, total_du - worked_before - du_remaining)
extra_total = max(0, restant - sum(s[2] for s in slots)) if slots else 0
slots_restants = len(slots)
worked_morning = 0
if jour.get("matin_entree") and jour.get("matin_sortie"):
worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"]))
elif jour.get("matin_entree") and jour["date"] == today:
# Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage
# (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée).
entree_min = hhmm_to_minutes(jour["matin_entree"])
elapsed = max(0, now_min - entree_min)
projected_to_fin_matin = max(0, matin_fin_min - entree_min)
worked_morning = max(elapsed, projected_to_fin_matin)
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
needed_aprem = max(0, needed_today - worked_morning)
aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
sortie_min = max(aprem_start + needed_aprem, aprem_fin_min)
if sortie_min > 23 * 60 + 59:
jour["sortie_cible"] = None # deficit too large to recover in one afternoon
else:
jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
entree_cible = None
if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min):
entree_min = aprem_fin_min - needed_aprem
if matin_fin_min < entree_min < pause_dejeuner_fin_min: # après le déjeuner, avant la reprise par défaut
entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
jour["entree_cible"] = entree_cible
worked_before += jour["travaille_min"]
reste_min = max(0, restant)
return {
"jours": jours,
@ -182,4 +174,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"total_du": minutes_to_hhmm(total_du),
"solde": minutes_to_hhmm(total_travaille - total_du),
"solde_min": total_travaille - total_du,
"reste_min": reste_min,
"reste": minutes_to_hhmm(reste_min),
"extra_min": extra_total,
"extra": minutes_to_hhmm(extra_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),
}