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}"

View File

@ -14,7 +14,10 @@
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
fin de demi-journée. Les heures visées (arrivée le matin, départ le soir) guident
les préconisations : le matin est planifié depuis ton arrivée visée, et le rattrapage
est casé avant ton départ visé tant que c'est possible. Propres à ton compte —
est casé avant ton départ visé tant que c'est possible. En rattrapage extrême :
départs du soir allongés en premier (jusqu'à minuit), puis arrivée du matin avancée
(jamais avant le début de la présence matin), puis retour de midi avancé (jamais
avant la fin de la présence matin). Propres à ton compte —
ne change rien pour les autres.
</p>

View File

@ -438,3 +438,70 @@ def test_compute_week_cible_en_cours_ne_recommande_jamais_un_depart_deja_passe(m
result = compute_week(pointages, [], HEURES_JOUR_MIN, plages)
jeudi = result["jours"][3]
assert jeudi["cible_aprem"] == "17:19"
# ── Rattrapage extrême : arrivées et retours de midi ──────────────────────────
def test_rattrapage_sature_departs_arrivees_puis_retour_de_midi(freeze_today):
"""Retard qui dépasse la capacité des départs du soir (minuit) : l'arrivée
du matin est avancée (jamais avant le début de « Présence matin »), puis le
retour de midi (jamais avant la fin de « Présence matin »). Arrivée visée
10:00, lun-jeu complets courts de 105 min/jour, vendredi congé, lundi vide :
420 min de retard — 281 dans le départ du soir (→ 23:59), 60 en arrivant à
09:00 (début Présence matin), 79 en reprenant à 12:11."""
plages = {"arrivee_visee": "10:00"}
pointages = [
_pointage("2026-07-13", "09:00", "12:00", "14:00", "17:03"),
_pointage("2026-07-14", "09:00", "12:00", "14:00", "17:03"),
_pointage("2026-07-15", "09:00", "12:00", "14:00", "17:03"),
_pointage("2026-07-16", "09:00", "12:00", "14:00", "17:03"),
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
lundi = result["jours"][5]
assert result["extra_min"] == 420
assert lundi["cible_matin_entree"] == "09:00" # arrivée avancée jusqu'au début de Présence matin
assert lundi["cible_matin"] == "12:00"
assert lundi["cible_aprem_entree"] == "12:11" # retour de midi avancé de 79 min
assert lundi["cible_aprem"] == "23:59" # départ saturé à minuit
def test_rattrapage_par_defaut_n_avance_pas_larrivee_avant_presence_matin(freeze_today):
"""Plages par défaut : l'arrivée visée (09:00) est déjà le début de
« Présence matin », rien à avancer — le levier restant est le retour de
midi, raccourci jusqu'à la fin de « Présence matin » (12:00)."""
pointages = [
_pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-17"),
_pointage("2026-07-20"),
]
conges = [{"date": "2026-07-17", "type": "jour"}]
result = compute_week(pointages, conges, HEURES_JOUR_MIN)
lundi = result["jours"][5]
assert lundi["cible_matin_entree"] == "09:00" # déjà au début de Présence matin
assert lundi["cible_matin"] == "12:00"
assert lundi["cible_aprem_entree"] == "12:00" # pause réduite jusqu'à la fin de Présence matin
assert lundi["cible_aprem"] == "23:59"
def test_jour_en_cours_ne_vise_pas_une_heure_passee(freeze_today):
"""Le jour même (gelé à 10:30), ni l'arrivée du matin ni le retour de midi
ne peuvent être proposés avant maintenant : l'arrivée reste à l'heure
visée, le retour de midi est borné par la fin de « Présence matin »."""
pointages = [
_pointage("2026-07-13", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-14", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-15", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-16", "09:00", "09:30", "14:00", "14:30"),
_pointage("2026-07-17"), # aujourd'hui 10:30, rien pointé
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert vendredi["cible_matin_entree"] == "09:00" # pas d'arrivée rétroactive
assert vendredi["cible_aprem_entree"] == "12:00" # borné par Présence matin, pas avant 10:30
assert vendredi["cible_aprem"] == "23:59"