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:
@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user