Répartit avance/retard sur les départs du soir, plus jamais sur le matin
Le rattrapage hebdomadaire ne portait que sur le retard et pouvait s'étaler sur le matin quand il avait de la marge. Objectif demandé : pouvoir planifier les horaires à l'avance pour arriver à 0 en fin de semaine, en ajustant uniquement les heures de départ du soir - dans un sens (retard) comme dans l'autre (avance, plafonnée par aprem_fin). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -120,14 +120,14 @@ 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
|
||||
|
||||
# Préconisations : répartit le manque restant à parts égales sur les demi-journées
|
||||
# restantes, dans la limite de la capacité de chaque créneau. La pause de midi
|
||||
# (matin_fin → reprise, 12:00 → 13:30 par défaut) est intouchable : la cible
|
||||
# matin ne dépasse jamais matin_fin, et le nominal matin qui n'y tient pas
|
||||
# bascule sur l'après-midi du même jour. Le matin démarre à l'arrivée visée
|
||||
# (sinon matin_debut) ; 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.
|
||||
# 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.
|
||||
for jour in jours:
|
||||
jour["cible_matin"] = None
|
||||
jour["cible_aprem"] = None
|
||||
@ -149,7 +149,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
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
|
||||
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
|
||||
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
|
||||
@ -171,42 +171,62 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
surplus = max(0, matin_nominal - cap)
|
||||
if surplus and not aprem_done:
|
||||
aprem_nominal += surplus
|
||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap),
|
||||
"debut": debut, "cap": cap, "cap_pref": cap})
|
||||
# Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa
|
||||
# capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus.
|
||||
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
|
||||
if not aprem_done:
|
||||
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min
|
||||
cap = max(0, 23 * 60 + 59 - debut)
|
||||
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
|
||||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal,
|
||||
"debut": debut, "cap": cap, "cap_pref": cap_pref})
|
||||
# floor_room = marge disponible pour avancer le départ en cas d'avance,
|
||||
# sans descendre sous le plancher aprem_fin (présence minimale obligatoire).
|
||||
floor_room = max(0, (debut + aprem_nominal) - aprem_fin_min)
|
||||
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
|
||||
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
|
||||
|
||||
extra_total = max(0, restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
||||
slots_restants = len(slots)
|
||||
ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
|
||||
aprem_slots = [s for s in slots if s["half"] == "aprem"]
|
||||
adjs = [0] * len(aprem_slots)
|
||||
|
||||
# Répartition équitable du rattrapage, plafonnée par la capacité de chaque créneau,
|
||||
# en deux passes : d'abord dans le cadre visé (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.
|
||||
adjs = [0] * slots_restants
|
||||
left = extra_total
|
||||
for cap_key in ("cap_pref", "cap"):
|
||||
# Répartition équitable de l'écart (retard ou avance), plafonnée par la capacité de
|
||||
# chaque créneau du soir, en deux passes en cas de retard : d'abord dans le cadre visé
|
||||
# (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).
|
||||
if ecart_total > 0:
|
||||
left = ecart_total
|
||||
for cap_key in ("cap_pref", "cap"):
|
||||
while left > 0:
|
||||
open_idx = [i for i, s in enumerate(aprem_slots) if adjs[i] < s[cap_key] - s["nominal"]]
|
||||
if not open_idx:
|
||||
break # tous les créneaux saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||||
base, rem = divmod(left, len(open_idx))
|
||||
for k, i in enumerate(open_idx):
|
||||
give = min(aprem_slots[i][cap_key] - aprem_slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||||
adjs[i] += give
|
||||
left -= give
|
||||
elif ecart_total < 0:
|
||||
left = -ecart_total
|
||||
while left > 0:
|
||||
open_idx = [i for i, s in enumerate(slots) if adjs[i] < s[cap_key] - s["nominal"]]
|
||||
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 saturés à ce plafond : passe suivante, ou rattrapage impossible
|
||||
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
|
||||
base, rem = divmod(left, len(open_idx))
|
||||
for k, i in enumerate(open_idx):
|
||||
give = min(slots[i][cap_key] - slots[i]["nominal"] - adjs[i], base + (1 if k < rem else 0))
|
||||
adjs[i] += give
|
||||
left -= give
|
||||
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
|
||||
adjs[i] -= take
|
||||
left -= take
|
||||
|
||||
for i, s in enumerate(slots):
|
||||
if adjs[i] <= 0:
|
||||
for i, s in enumerate(aprem_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
|
||||
sortie_min = max(sortie_min, aprem_fin_min)
|
||||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||
|
||||
extra_total = max(0, ecart_total)
|
||||
avance_total = max(0, -ecart_total)
|
||||
reste_min = max(0, restant)
|
||||
|
||||
return {
|
||||
@ -219,6 +239,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
"reste": minutes_to_hhmm(reste_min),
|
||||
"extra_min": extra_total,
|
||||
"extra": minutes_to_hhmm(extra_total),
|
||||
"avance_min": avance_total,
|
||||
"avance": minutes_to_hhmm(avance_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),
|
||||
|
||||
Reference in New Issue
Block a user