Rattrapage hebdomadaire : arrivées et retours de midi en secours des départs

Pour tenir les heures de la semaine, les préconisations n'ajustaient que
les départs du soir : un retard trop gros était déclaré rattrapage
impossible dès minuit atteint. Deux leviers s'enchaînent désormais :
avancer les arrivées du matin (jamais avant le début de la présence
matin, le retour de repos, ni l'heure actuelle), puis les retours de midi
(jamais avant la fin de la présence matin, ou la sortie réelle du matin).
Les entrées déjà pointées restent intactes ; l'aide des Réglages
documente la cascade.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-10 17:18:54 +02:00
parent d760842121
commit c0ed99de9d
3 changed files with 150 additions and 15 deletions

View File

@ -583,6 +583,24 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
return max(0, du)
def _repartir(left: int, rooms: list[int]) -> tuple[list[int], int]:
"""Répartit équitablement `left` minutes sur des créneaux de capacités
`rooms` : chacun reçoit au plus sa capacité, à parts égales autant que
possible ; le surplus d'un créneau saturé se reporte sur les autres.
Renvoie (attributions par créneau, minutes restant à placer)."""
adjs = [0] * len(rooms)
while left > 0:
open_idx = [i for i, room in enumerate(rooms) if adjs[i] < room]
if not open_idx:
break
base, rem = divmod(left, len(open_idx))
for k, i in enumerate(open_idx):
give = min(rooms[i] - adjs[i], base + (1 if k < rem else 0))
adjs[i] += give
left -= give
return adjs, left
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None, contexte: list[dict] | None = None) -> dict:
"""Compute totals for a list of pointages (one week).
@ -748,12 +766,16 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# 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.
# restantes, pour viser un solde à 0 en fin de semaine. En cas de retard, si
# les départs du soir ne suffisent pas (saturés à minuit), deux leviers
# supplémentaires entrent en jeu dans l'ordre, bornés par les plages de
# présence des préférences : avancer les arrivées du matin (jamais avant le
# 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).
# Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi.
for jour in jours:
jour["cible_matin"] = None
jour["cible_aprem"] = None
@ -829,6 +851,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
# (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).
left = 0
if ecart_total > 0:
left = ecart_total
for cap_key in ("cap_pref", "cap"):
@ -842,16 +865,57 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
adjs[i] += give
left -= give
elif ecart_total < 0:
left = -ecart_total
while left > 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"]]
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, len(open_idx))
base, rem = divmod(left_avance, len(open_idx))
for k, i in enumerate(open_idx):
take = min(aprem_slots[i]["floor_room"] + adjs[i], base + (1 if k < rem else 0))
adjs[i] -= take
left -= 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
# les retours de midi (jamais avant la fin de « Présence matin », ni avant
# la sortie réelle du matin s'il est déjà pointé). Une entrée déjà pointée
# n'est pas modifiable ; le jour en cours ne vise pas une heure passée.
arrivee_advs: dict[int, int] = {} # id(slot matin) → minutes d'avance à l'arrivée
reprise_advs: dict[int, int] = {} # id(slot aprem) → minutes d'avance au retour de midi
if left > 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
floor = max(matin_debut_min, s["jour"].get("retour_min") or 0)
if s["jour"]["date"] == today:
floor = max(floor, now_min)
room = max(0, s["debut"] - floor)
if room:
candidats.append((id(s), room))
gains, left = _repartir(left, [room for _, room in candidats])
arrivee_advs = {key: gain for (key, _), gain in zip(candidats, gains)}
if left > 0:
candidats = []
for s in aprem_slots:
j = s["jour"]
if j.get("aprem_entree"):
continue # reprise déjà pointée : non modifiable
# Plancher : fin de « Présence matin » — la pause de midi (entre les
# deux présences) peut être raccourcie jusqu'à zéro à l'extrême, mais
# le retour n'est jamais proposé avant la fin du matin réel.
floor = matin_fin_min
if j.get("matin_sortie"):
floor = max(floor, hhmm_to_minutes(j["matin_sortie"]))
if j["date"] == today:
floor = max(floor, now_min)
room = max(0, s["debut"] - floor)
if room:
candidats.append((id(s), room))
gains, left = _repartir(left, [room for _, room in candidats])
reprise_advs = {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,
@ -863,9 +927,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
and not jour.get(f"{half}_sortie")
)
# Le matin propose toujours son horaire nominal (jamais ajusté par le rattrapage
# Le matin propose son horaire nominal (jamais ajusté par le rattrapage
# hebdomadaire) ; l'après-midi propose son horaire nominal éventuellement décalé
# par l'écart (retard ou avance) réparti ci-dessus.
# par l'écart (retard ou avance) réparti ci-dessus. En rattrapage extrême, les
# arrivées du matin et les retours de midi peuvent être avancés (ci-dessus).
for s in slots:
if s["half"] != "matin":
continue
@ -874,7 +939,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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
entree = minutes_to_time(s["debut"] - arrivee_advs.get(id(s), 0))
s["jour"]["cible_matin_entree"] = entree
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
@ -882,7 +947,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 = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
entree = minutes_to_time(s["debut"] - reprise_advs.get(id(s), 0))
s["jour"]["cible_aprem_entree"] = entree
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"