Préconisations d'horaire : cascade d'avance et correctif de double comptage

Une avance que les départs du soir ne peuvent pas écouler (plancher de
présence) s'écoule désormais en cascade miroir du rattrapage : arrivées
du matin retardées, puis retours de midi, toujours bornés par les plages
de présence.

Une demi-journée en cours qui dépasse son nominal (ex. sortie de midi
oubliée) comptait son temps écoulé deux fois : suivre les cibles
sur-livrait jusqu'à 1h45 par semaine. Le créneau saturé propose
maintenant « sortir maintenant » et l'écart se reporte sur les autres
créneaux ; plus de retard fantôme affiché.

Le README liste désormais l'ensemble des règles de préconisation.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-11 14:04:37 +02:00
parent c0ed99de9d
commit 45b450c1ca
5 changed files with 275 additions and 36 deletions

View File

@ -613,6 +613,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0
matin_debut_min = hhmm_to_minutes(plages["matin_debut"])
matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_debut_min = hhmm_to_minutes(plages["aprem_debut"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
arrivee_visee_min = hhmm_to_minutes(plages["arrivee_visee"]) if plages.get("arrivee_visee") else matin_debut_min
@ -773,8 +774,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# début de « Présence matin », l'heure de retour après repos, ni avant
# maintenant pour le jour en cours), puis avancer les retours de midi (la
# pause peut être raccourcie, jamais avant la fin de « Présence matin »).
# En cas d'avance, on avance les départs symétriquement, sans jamais
# descendre sous le plancher aprem_fin (présence minimale obligatoire).
# En cas d'avance, cascade miroir : départs du soir avancés (jamais sous la
# fin de « Présence après-midi »), puis arrivées du matin retardées (jamais
# après le début de « Présence matin »), puis retours de midi retardés
# (jamais après le début de « Présence après-midi » : la pause s'allonge).
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi.
for jour in jours:
jour["cible_matin"] = None
@ -784,23 +787,19 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
restant = total_du - total_travaille
# 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
retour = jour.get("retour_min") or 0
if jour.get("matin_entree") and not jour.get("matin_sortie"):
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour))
if jour.get("aprem_entree") and not jour.get("aprem_sortie"):
restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour))
slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage
# restant_planif : reste à planifier. 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é, sinon la même période compte à la
# fois comme travaillée et comme à venir. Sauf si le créneau est saturé (temps
# écoulé ≥ nominal) : la sortie cible retombe alors à « maintenant », la période
# n'est pas re-planifiée, et son excédent déjà travaillé est de facto compté —
# le réintégrer ferait doubler le compte.
restant_planif = restant
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
@ -813,6 +812,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
if (jour.get("retour_min") or 0) >= 24 * 60:
continue
# Temps déjà écoulé des demi-journées en cours aujourd'hui (entrée pointée
# sans sortie) — cf. restant_planif ci-dessus.
ecoule = {}
if jour["date"] == today:
retour = jour.get("retour_min") or 0
for half in ("matin", "aprem"):
if jour.get(f"{half}_entree") and not jour.get(f"{half}_sortie"):
ecoule[half] = max(0, now_min - max(hhmm_to_minutes(jour[f"{half}_entree"]), retour))
matin_nominal = jour["matin_nominal_min"]
aprem_nominal = jour["aprem_nominal_min"]
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg
@ -829,8 +836,15 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
aprem_nominal += surplus
# 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.
if cap > 0 and matin_nominal > 0:
slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut})
# Une demi-journée en cours garde toujours son créneau : l'entrée ouverte
# doit recevoir une heure de sortie, ne serait-ce que « maintenant ».
if (cap > 0 and matin_nominal > 0) or "matin" in ecoule:
nominal = min(matin_nominal, cap)
sature = "matin" in ecoule and ecoule["matin"] >= nominal
if "matin" in ecoule and not sature:
restant_planif += ecoule["matin"]
slots.append({"jour": jour, "half": "matin", "nominal": nominal, "debut": debut,
"sature": sature})
if not aprem_done:
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
cap = max(0, 23 * 60 + 59 - debut)
@ -838,11 +852,17 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# 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)
sature = "aprem" in ecoule and ecoule["aprem"] >= aprem_nominal
if "aprem" in ecoule and not sature:
restant_planif += ecoule["aprem"]
slots.append({"jour": jour, "half": "aprem", "nominal": aprem_nominal, "debut": debut,
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room})
"cap": cap, "cap_pref": cap_pref, "floor_room": floor_room,
"sature": sature})
slots_restants = len(slots)
ecart_total = (restant_planif - sum(s["nominal"] for s in slots)) if slots else 0
# Les créneaux saturés sortent du plan : leur sortie cible est « maintenant »,
# ils ne peuvent rien rattraper ni écouler.
ecart_total = (restant_planif - sum(s["nominal"] for s in slots if not s.get("sature"))) if slots else 0
aprem_slots = [s for s in slots if s["half"] == "aprem"]
adjs = [0] * len(aprem_slots)
@ -852,11 +872,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# 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).
left = 0
left_avance = 0
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"]]
open_idx = [i for i, s in enumerate(aprem_slots)
if not s.get("sature") and 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))
@ -867,7 +889,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
elif ecart_total < 0:
left_avance = -ecart_total
while left_avance > 0:
open_idx = [i for i, s in enumerate(aprem_slots) if -adjs[i] < s["floor_room"]]
open_idx = [i for i, s in enumerate(aprem_slots)
if not s.get("sature") and -adjs[i] < s["floor_room"]]
if not open_idx:
break # tous les créneaux déjà au plancher : l'avance restante n'est pas plaçable
base, rem = divmod(left_avance, len(open_idx))
@ -875,7 +898,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
adjs[i] -= take
left_avance -= take
# Si les départs du soir ne suffisent pas à absorber le retard, deux leviers
# supplémentaires dans l'ordre, bornés par les plages de présence : avancer
# les arrivées du matin (jamais avant le début de « Présence matin »), puis
@ -917,6 +939,38 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
gains, left = _repartir(left, [room for _, room in candidats])
reprise_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
# Miroir pour l'avance : si les départs du soir ne peuvent pas tout écouler
# (plancher aprem_fin), retarder d'abord les arrivées du matin (jamais après
# le début de « Présence matin »), puis les retours de midi (jamais après
# le début de « Présence après-midi ») — la pause de midi s'allonge
# d'autant. Une entrée déjà pointée n'est pas modifiable ; le jour en cours
# ne vise pas une heure déjà passée.
arrivee_delays: dict[int, int] = {} # id(slot matin) → minutes de retard à l'arrivée
reprise_delays: dict[int, int] = {} # id(slot aprem) → minutes de retard au retour de midi
if left_avance > 0:
candidats = []
for s in slots:
if s["half"] != "matin" or s["jour"].get("matin_entree"):
continue # arrivée déjà pointée : non modifiable
base = max(s["debut"], now_min) if s["jour"]["date"] == today else s["debut"]
room = max(0, matin_debut_min - base)
if room:
candidats.append((id(s), room))
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
arrivee_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
if left_avance > 0:
candidats = []
for s in aprem_slots:
j = s["jour"]
if j.get("aprem_entree"):
continue # reprise déjà pointée : non modifiable
base = max(s["debut"], now_min) if j["date"] == today else s["debut"]
room = max(0, aprem_debut_min - base)
if room:
candidats.append((id(s), room))
gains, left_avance = _repartir(left_avance, [room for _, room in candidats])
reprise_delays = {key: gain for (key, _), gain in zip(candidats, gains)}
def _en_cours(jour, half: str) -> bool:
"""True si cette demi-journée est déjà commencée aujourd'hui (entrée pointée,
pas encore de sortie) : la cible ne peut alors pas retomber avant l'heure
@ -939,7 +993,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
sortie_min = s["debut"] + s["nominal"]
if _en_cours(s["jour"], "matin"):
sortie_min = max(sortie_min, now_min)
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0))
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0) + arrivee_delays.get(id(s), 0))
s["jour"]["cible_matin_entree"] = entree
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
@ -947,7 +1001,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
if _en_cours(s["jour"], "aprem"):
sortie_min = max(sortie_min, now_min)
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0))
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0) + reprise_delays.get(id(s), 0))
s["jour"]["cible_aprem_entree"] = entree
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"